musubi-sdd 6.1.2 → 6.2.1

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.
@@ -0,0 +1,378 @@
1
+ /**
2
+ * SprintReporter Implementation
3
+ *
4
+ * Generates sprint completion reports.
5
+ *
6
+ * Requirement: IMP-6.2-003-04
7
+ * Design: Section 4.4
8
+ */
9
+
10
+ const fs = require('fs').promises;
11
+ const path = require('path');
12
+
13
+ /**
14
+ * Default configuration
15
+ */
16
+ const DEFAULT_CONFIG = {
17
+ storageDir: 'storage/reports'
18
+ };
19
+
20
+ /**
21
+ * SprintReporter
22
+ *
23
+ * Generates and manages sprint reports.
24
+ */
25
+ class SprintReporter {
26
+ /**
27
+ * @param {Object} config - Configuration options
28
+ */
29
+ constructor(config = {}) {
30
+ this.config = { ...DEFAULT_CONFIG, ...config };
31
+ }
32
+
33
+ /**
34
+ * Generate sprint completion report
35
+ * @param {Object} sprint - Sprint data
36
+ * @returns {Promise<Object>} Generated report
37
+ */
38
+ async generateReport(sprint) {
39
+ const report = {
40
+ id: `RPT-${sprint.id}-${Date.now()}`,
41
+ sprintId: sprint.id,
42
+ sprintName: sprint.name,
43
+ featureId: sprint.featureId,
44
+ generatedAt: new Date().toISOString(),
45
+ period: {
46
+ start: sprint.startDate,
47
+ end: sprint.endDate,
48
+ startedAt: sprint.startedAt,
49
+ completedAt: sprint.completedAt
50
+ },
51
+ metrics: this.calculateMetrics(sprint),
52
+ taskSummary: this.summarizeTasks(sprint),
53
+ velocityAnalysis: this.analyzeVelocity(sprint),
54
+ recommendations: this.generateRecommendations(sprint)
55
+ };
56
+
57
+ await this.saveReport(report);
58
+
59
+ return report;
60
+ }
61
+
62
+ /**
63
+ * Calculate sprint metrics
64
+ * @param {Object} sprint - Sprint data
65
+ * @returns {Object} Metrics
66
+ */
67
+ calculateMetrics(sprint) {
68
+ const tasks = sprint.tasks || [];
69
+ const totalPoints = tasks.reduce((sum, t) => sum + (t.storyPoints || 0), 0);
70
+ const completedTasks = tasks.filter(t => t.status === 'done');
71
+ const completedPoints = completedTasks.reduce((sum, t) => sum + (t.storyPoints || 0), 0);
72
+
73
+ const plannedVelocity = sprint.velocity || 0;
74
+ const actualVelocity = completedPoints;
75
+ const velocityDiff = actualVelocity - plannedVelocity;
76
+
77
+ return {
78
+ totalTasks: tasks.length,
79
+ completedTasks: completedTasks.length,
80
+ incompleteTasks: tasks.length - completedTasks.length,
81
+ totalPoints,
82
+ completedPoints,
83
+ remainingPoints: totalPoints - completedPoints,
84
+ completionRate: tasks.length > 0
85
+ ? Math.round((completedTasks.length / tasks.length) * 100)
86
+ : 0,
87
+ pointsCompletionRate: totalPoints > 0
88
+ ? Math.round((completedPoints / totalPoints) * 100)
89
+ : 0,
90
+ plannedVelocity,
91
+ actualVelocity,
92
+ velocityDiff,
93
+ velocityAccuracy: plannedVelocity > 0
94
+ ? Math.round((actualVelocity / plannedVelocity) * 100)
95
+ : 0
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Summarize tasks by status and priority
101
+ * @param {Object} sprint - Sprint data
102
+ * @returns {Object} Task summary
103
+ */
104
+ summarizeTasks(sprint) {
105
+ const tasks = sprint.tasks || [];
106
+
107
+ const byStatus = {
108
+ todo: tasks.filter(t => t.status === 'todo'),
109
+ inProgress: tasks.filter(t => t.status === 'in-progress'),
110
+ done: tasks.filter(t => t.status === 'done')
111
+ };
112
+
113
+ const byPriority = {
114
+ critical: tasks.filter(t => t.priority === 'critical'),
115
+ high: tasks.filter(t => t.priority === 'high'),
116
+ medium: tasks.filter(t => t.priority === 'medium'),
117
+ low: tasks.filter(t => t.priority === 'low')
118
+ };
119
+
120
+ const completedByPriority = {
121
+ critical: byPriority.critical.filter(t => t.status === 'done').length,
122
+ high: byPriority.high.filter(t => t.status === 'done').length,
123
+ medium: byPriority.medium.filter(t => t.status === 'done').length,
124
+ low: byPriority.low.filter(t => t.status === 'done').length
125
+ };
126
+
127
+ return {
128
+ byStatus: {
129
+ todo: byStatus.todo.length,
130
+ inProgress: byStatus.inProgress.length,
131
+ done: byStatus.done.length
132
+ },
133
+ byPriority: {
134
+ critical: byPriority.critical.length,
135
+ high: byPriority.high.length,
136
+ medium: byPriority.medium.length,
137
+ low: byPriority.low.length
138
+ },
139
+ completedByPriority,
140
+ incompleteTasks: [...byStatus.todo, ...byStatus.inProgress].map(t => ({
141
+ id: t.id,
142
+ title: t.title,
143
+ priority: t.priority,
144
+ storyPoints: t.storyPoints,
145
+ status: t.status
146
+ }))
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Analyze velocity trends
152
+ * @param {Object} sprint - Sprint data
153
+ * @returns {Object} Velocity analysis
154
+ */
155
+ analyzeVelocity(sprint) {
156
+ const metrics = this.calculateMetrics(sprint);
157
+
158
+ let status;
159
+ if (metrics.velocityAccuracy >= 90 && metrics.velocityAccuracy <= 110) {
160
+ status = 'on-target';
161
+ } else if (metrics.velocityAccuracy > 110) {
162
+ status = 'over-performing';
163
+ } else if (metrics.velocityAccuracy >= 70) {
164
+ status = 'slightly-under';
165
+ } else {
166
+ status = 'under-performing';
167
+ }
168
+
169
+ return {
170
+ planned: metrics.plannedVelocity,
171
+ actual: metrics.actualVelocity,
172
+ difference: metrics.velocityDiff,
173
+ accuracy: metrics.velocityAccuracy,
174
+ status
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Generate recommendations based on sprint results
180
+ * @param {Object} sprint - Sprint data
181
+ * @returns {Array} Recommendations
182
+ */
183
+ generateRecommendations(sprint) {
184
+ const recommendations = [];
185
+ const metrics = this.calculateMetrics(sprint);
186
+ const taskSummary = this.summarizeTasks(sprint);
187
+
188
+ // Velocity recommendations
189
+ if (metrics.velocityAccuracy < 70) {
190
+ recommendations.push({
191
+ type: 'velocity',
192
+ severity: 'high',
193
+ message: 'スプリントの実績ベロシティが計画の70%未満でした。次のスプリントでは計画ベロシティを下げることを検討してください。'
194
+ });
195
+ } else if (metrics.velocityAccuracy > 130) {
196
+ recommendations.push({
197
+ type: 'velocity',
198
+ severity: 'medium',
199
+ message: '計画以上のベロシティを達成しました。次のスプリントでは計画ベロシティを上げることを検討してください。'
200
+ });
201
+ }
202
+
203
+ // Incomplete critical tasks
204
+ const incompleteCritical = taskSummary.byPriority.critical - taskSummary.completedByPriority.critical;
205
+ if (incompleteCritical > 0) {
206
+ recommendations.push({
207
+ type: 'priority',
208
+ severity: 'critical',
209
+ message: `${incompleteCritical}件のクリティカルタスクが未完了です。次のスプリントで優先的に対応してください。`
210
+ });
211
+ }
212
+
213
+ // High number of incomplete tasks
214
+ if (metrics.completionRate < 50) {
215
+ recommendations.push({
216
+ type: 'planning',
217
+ severity: 'high',
218
+ message: 'タスク完了率が50%未満です。タスクの見積もりや優先順位付けの改善を検討してください。'
219
+ });
220
+ }
221
+
222
+ // Many in-progress tasks
223
+ if (taskSummary.byStatus.inProgress > 3) {
224
+ recommendations.push({
225
+ type: 'wip',
226
+ severity: 'medium',
227
+ message: '進行中のタスクが多すぎます。WIP制限を設けてフォーカスを高めることを検討してください。'
228
+ });
229
+ }
230
+
231
+ return recommendations;
232
+ }
233
+
234
+ /**
235
+ * Generate markdown report
236
+ * @param {Object} sprint - Sprint data
237
+ * @returns {Promise<string>} Markdown report
238
+ */
239
+ async generateMarkdownReport(sprint) {
240
+ const report = await this.generateReport(sprint);
241
+ const lines = [];
242
+
243
+ lines.push(`# Sprint Report: ${report.sprintName}`);
244
+ lines.push('');
245
+ lines.push(`**Generated:** ${report.generatedAt}`);
246
+ lines.push(`**Feature:** ${report.featureId || 'N/A'}`);
247
+ lines.push(`**Period:** ${report.period.start} - ${report.period.end}`);
248
+ lines.push('');
249
+
250
+ // Metrics
251
+ lines.push('## Metrics');
252
+ lines.push('');
253
+ lines.push('| Metric | Value |');
254
+ lines.push('|--------|-------|');
255
+ lines.push(`| Total Tasks | ${report.metrics.totalTasks} |`);
256
+ lines.push(`| Completed Tasks | ${report.metrics.completedTasks} |`);
257
+ lines.push(`| Completion Rate | ${report.metrics.completionRate}% |`);
258
+ lines.push(`| Total Points | ${report.metrics.totalPoints} |`);
259
+ lines.push(`| Completed Points | ${report.metrics.completedPoints} |`);
260
+ lines.push(`| Points Completion | ${report.metrics.pointsCompletionRate}% |`);
261
+ lines.push(`| Planned Velocity | ${report.metrics.plannedVelocity} |`);
262
+ lines.push(`| Actual Velocity | ${report.metrics.actualVelocity} |`);
263
+ lines.push(`| Velocity Accuracy | ${report.metrics.velocityAccuracy}% |`);
264
+ lines.push('');
265
+
266
+ // Velocity Analysis
267
+ lines.push('## Velocity Analysis');
268
+ lines.push('');
269
+ const va = report.velocityAnalysis;
270
+ const statusEmoji = {
271
+ 'on-target': '✅',
272
+ 'over-performing': '🚀',
273
+ 'slightly-under': '⚠️',
274
+ 'under-performing': '❌'
275
+ };
276
+ lines.push(`Status: ${statusEmoji[va.status] || '❓'} **${va.status}**`);
277
+ lines.push('');
278
+
279
+ // Task Summary
280
+ lines.push('## Task Summary');
281
+ lines.push('');
282
+ lines.push('### By Status');
283
+ lines.push(`- ⬜ Todo: ${report.taskSummary.byStatus.todo}`);
284
+ lines.push(`- 🔄 In Progress: ${report.taskSummary.byStatus.inProgress}`);
285
+ lines.push(`- ✅ Done: ${report.taskSummary.byStatus.done}`);
286
+ lines.push('');
287
+
288
+ lines.push('### By Priority');
289
+ lines.push(`- 🔴 Critical: ${report.taskSummary.completedByPriority.critical}/${report.taskSummary.byPriority.critical}`);
290
+ lines.push(`- 🟠 High: ${report.taskSummary.completedByPriority.high}/${report.taskSummary.byPriority.high}`);
291
+ lines.push(`- 🟡 Medium: ${report.taskSummary.completedByPriority.medium}/${report.taskSummary.byPriority.medium}`);
292
+ lines.push(`- 🟢 Low: ${report.taskSummary.completedByPriority.low}/${report.taskSummary.byPriority.low}`);
293
+ lines.push('');
294
+
295
+ // Incomplete Tasks
296
+ if (report.taskSummary.incompleteTasks.length > 0) {
297
+ lines.push('### Incomplete Tasks');
298
+ lines.push('');
299
+ for (const task of report.taskSummary.incompleteTasks) {
300
+ lines.push(`- **${task.id}**: ${task.title} (${task.priority}, ${task.storyPoints}pt)`);
301
+ }
302
+ lines.push('');
303
+ }
304
+
305
+ // Recommendations
306
+ if (report.recommendations.length > 0) {
307
+ lines.push('## Recommendations');
308
+ lines.push('');
309
+ const severityEmoji = {
310
+ critical: '🔴',
311
+ high: '🟠',
312
+ medium: '🟡',
313
+ low: '🟢'
314
+ };
315
+ for (const rec of report.recommendations) {
316
+ lines.push(`${severityEmoji[rec.severity] || '❓'} **${rec.type}**: ${rec.message}`);
317
+ lines.push('');
318
+ }
319
+ }
320
+
321
+ return lines.join('\n');
322
+ }
323
+
324
+ /**
325
+ * Save report to storage
326
+ * @param {Object} report - Report to save
327
+ */
328
+ async saveReport(report) {
329
+ await this.ensureStorageDir();
330
+
331
+ const filePath = path.join(this.config.storageDir, `${report.id}.json`);
332
+ await fs.writeFile(filePath, JSON.stringify(report, null, 2), 'utf-8');
333
+ }
334
+
335
+ /**
336
+ * Load report from storage
337
+ * @param {string} reportId - Report ID
338
+ * @returns {Promise<Object|null>} Report
339
+ */
340
+ async loadReport(reportId) {
341
+ try {
342
+ const filePath = path.join(this.config.storageDir, `${reportId}.json`);
343
+ const content = await fs.readFile(filePath, 'utf-8');
344
+ return JSON.parse(content);
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
349
+
350
+ /**
351
+ * List reports for sprint
352
+ * @param {string} sprintId - Sprint ID
353
+ * @returns {Promise<Array>} Report list
354
+ */
355
+ async listReports(sprintId) {
356
+ try {
357
+ const files = await fs.readdir(this.config.storageDir);
358
+ return files
359
+ .filter(f => f.includes(sprintId) && f.endsWith('.json'))
360
+ .map(f => f.replace('.json', ''));
361
+ } catch {
362
+ return [];
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Ensure storage directory exists
368
+ */
369
+ async ensureStorageDir() {
370
+ try {
371
+ await fs.access(this.config.storageDir);
372
+ } catch {
373
+ await fs.mkdir(this.config.storageDir, { recursive: true });
374
+ }
375
+ }
376
+ }
377
+
378
+ module.exports = { SprintReporter };
@@ -0,0 +1,209 @@
1
+ /**
2
+ * TransitionRecorder Implementation
3
+ *
4
+ * Records stage transitions with timestamps and reviewers.
5
+ *
6
+ * Requirement: IMP-6.2-003-02
7
+ * Design: Section 4.2
8
+ */
9
+
10
+ const fs = require('fs').promises;
11
+ const path = require('path');
12
+
13
+ /**
14
+ * Default configuration
15
+ */
16
+ const DEFAULT_CONFIG = {
17
+ storageDir: 'storage/transitions'
18
+ };
19
+
20
+ /**
21
+ * TransitionRecorder
22
+ *
23
+ * Records and manages workflow stage transitions.
24
+ */
25
+ class TransitionRecorder {
26
+ /**
27
+ * @param {Object} config - Configuration options
28
+ */
29
+ constructor(config = {}) {
30
+ this.config = { ...DEFAULT_CONFIG, ...config };
31
+ }
32
+
33
+ /**
34
+ * Record a stage transition
35
+ * @param {string} featureId - Feature ID
36
+ * @param {Object} transition - Transition data
37
+ * @returns {Promise<Object>} Created transition record
38
+ */
39
+ async recordTransition(featureId, transition) {
40
+ const history = await this.getHistory(featureId) || {
41
+ featureId,
42
+ transitions: [],
43
+ createdAt: new Date().toISOString()
44
+ };
45
+
46
+ const record = {
47
+ id: `TR-${Date.now()}`,
48
+ fromStage: transition.fromStage,
49
+ toStage: transition.toStage,
50
+ status: transition.status || 'completed',
51
+ reviewer: transition.reviewer || null,
52
+ reviewResult: transition.reviewResult || null,
53
+ artifacts: transition.artifacts || [],
54
+ notes: transition.notes || null,
55
+ timestamp: new Date().toISOString()
56
+ };
57
+
58
+ history.transitions.push(record);
59
+ history.updatedAt = new Date().toISOString();
60
+
61
+ await this.saveHistory(featureId, history);
62
+
63
+ return record;
64
+ }
65
+
66
+ /**
67
+ * Get transition history for feature
68
+ * @param {string} featureId - Feature ID
69
+ * @returns {Promise<Object|null>} Transition history
70
+ */
71
+ async getHistory(featureId) {
72
+ try {
73
+ const filePath = path.join(
74
+ this.config.storageDir,
75
+ `${featureId}-transitions.json`
76
+ );
77
+ const content = await fs.readFile(filePath, 'utf-8');
78
+ return JSON.parse(content);
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Get last transition for feature
86
+ * @param {string} featureId - Feature ID
87
+ * @returns {Promise<Object|null>} Last transition record
88
+ */
89
+ async getLastTransition(featureId) {
90
+ const history = await this.getHistory(featureId);
91
+ if (!history || history.transitions.length === 0) {
92
+ return null;
93
+ }
94
+ return history.transitions[history.transitions.length - 1];
95
+ }
96
+
97
+ /**
98
+ * Get transitions to a specific stage
99
+ * @param {string} featureId - Feature ID
100
+ * @param {string} targetStage - Target stage
101
+ * @returns {Promise<Array>} Transitions to stage
102
+ */
103
+ async getTransitionsByStage(featureId, targetStage) {
104
+ const history = await this.getHistory(featureId);
105
+ if (!history) {
106
+ return [];
107
+ }
108
+ return history.transitions.filter(t => t.toStage === targetStage);
109
+ }
110
+
111
+ /**
112
+ * Calculate average transition time between stages
113
+ * @param {string} featureId - Feature ID
114
+ * @returns {Promise<number>} Average time in milliseconds
115
+ */
116
+ async calculateAverageTransitionTime(featureId) {
117
+ const history = await this.getHistory(featureId);
118
+ if (!history || history.transitions.length < 2) {
119
+ return 0;
120
+ }
121
+
122
+ const transitions = history.transitions;
123
+ let totalTime = 0;
124
+ let count = 0;
125
+
126
+ for (let i = 1; i < transitions.length; i++) {
127
+ const prev = new Date(transitions[i - 1].timestamp).getTime();
128
+ const curr = new Date(transitions[i].timestamp).getTime();
129
+ totalTime += curr - prev;
130
+ count++;
131
+ }
132
+
133
+ return count > 0 ? Math.round(totalTime / count) : 0;
134
+ }
135
+
136
+ /**
137
+ * Get transition statistics
138
+ * @param {string} featureId - Feature ID
139
+ * @returns {Promise<Object>} Transition statistics
140
+ */
141
+ async getTransitionStats(featureId) {
142
+ const history = await this.getHistory(featureId);
143
+ if (!history) {
144
+ return {
145
+ totalTransitions: 0,
146
+ successfulTransitions: 0,
147
+ failedTransitions: 0,
148
+ averageTime: 0,
149
+ stageTransitions: {}
150
+ };
151
+ }
152
+
153
+ const transitions = history.transitions;
154
+ const stageTransitions = {};
155
+
156
+ let successful = 0;
157
+ let failed = 0;
158
+
159
+ for (const t of transitions) {
160
+ if (t.status === 'completed') {
161
+ successful++;
162
+ } else if (t.status === 'failed' || t.status === 'rejected') {
163
+ failed++;
164
+ }
165
+
166
+ const key = `${t.fromStage}->${t.toStage}`;
167
+ stageTransitions[key] = (stageTransitions[key] || 0) + 1;
168
+ }
169
+
170
+ const averageTime = await this.calculateAverageTransitionTime(featureId);
171
+
172
+ return {
173
+ totalTransitions: transitions.length,
174
+ successfulTransitions: successful,
175
+ failedTransitions: failed,
176
+ averageTime,
177
+ stageTransitions
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Save transition history
183
+ * @param {string} featureId - Feature ID
184
+ * @param {Object} history - History to save
185
+ */
186
+ async saveHistory(featureId, history) {
187
+ await this.ensureStorageDir();
188
+
189
+ const filePath = path.join(
190
+ this.config.storageDir,
191
+ `${featureId}-transitions.json`
192
+ );
193
+
194
+ await fs.writeFile(filePath, JSON.stringify(history, null, 2), 'utf-8');
195
+ }
196
+
197
+ /**
198
+ * Ensure storage directory exists
199
+ */
200
+ async ensureStorageDir() {
201
+ try {
202
+ await fs.access(this.config.storageDir);
203
+ } catch {
204
+ await fs.mkdir(this.config.storageDir, { recursive: true });
205
+ }
206
+ }
207
+ }
208
+
209
+ module.exports = { TransitionRecorder };