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.
@@ -0,0 +1,581 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const yaml = require('js-yaml');
4
+
5
+ const DEFAULT_BOLT_STAGE_MAP = {
6
+ 'simple-construction-bolt': ['plan', 'implement', 'test'],
7
+ 'ddd-construction-bolt': ['model', 'design', 'adr', 'implement', 'test'],
8
+ 'spike-bolt': ['explore', 'document']
9
+ };
10
+
11
+ function parseFrontmatter(content) {
12
+ const match = String(content || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
13
+ if (!match) {
14
+ return {};
15
+ }
16
+
17
+ try {
18
+ const parsed = yaml.load(match[1]);
19
+ return parsed && typeof parsed === 'object' ? parsed : {};
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ function readFileSafe(filePath) {
26
+ try {
27
+ return fs.readFileSync(filePath, 'utf8');
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function parseYamlFile(filePath) {
34
+ const content = readFileSafe(filePath);
35
+ if (content == null) {
36
+ return null;
37
+ }
38
+
39
+ try {
40
+ const parsed = yaml.load(content);
41
+ return parsed && typeof parsed === 'object' ? parsed : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function listSubdirectories(dirPath) {
48
+ try {
49
+ return fs.readdirSync(dirPath, { withFileTypes: true })
50
+ .filter((entry) => entry.isDirectory())
51
+ .map((entry) => entry.name)
52
+ .sort((a, b) => a.localeCompare(b));
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ function listMarkdownFiles(dirPath) {
59
+ try {
60
+ return fs.readdirSync(dirPath, { withFileTypes: true })
61
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
62
+ .map((entry) => entry.name)
63
+ .sort((a, b) => a.localeCompare(b));
64
+ } catch {
65
+ return [];
66
+ }
67
+ }
68
+
69
+ function normalizeStatus(rawStatus) {
70
+ if (typeof rawStatus !== 'string') {
71
+ return 'unknown';
72
+ }
73
+
74
+ const normalized = rawStatus.toLowerCase().trim().replace(/[\s_]+/g, '-');
75
+
76
+ if (['complete', 'completed', 'done', 'finished', 'closed', 'resolved'].includes(normalized)) {
77
+ return 'completed';
78
+ }
79
+
80
+ if (['blocked'].includes(normalized)) {
81
+ return 'blocked';
82
+ }
83
+
84
+ if ([
85
+ 'in-progress',
86
+ 'inprogress',
87
+ 'active',
88
+ 'started',
89
+ 'wip',
90
+ 'working',
91
+ 'ready',
92
+ 'construction'
93
+ ].includes(normalized)) {
94
+ return 'in_progress';
95
+ }
96
+
97
+ if (['draft', 'pending', 'planned', 'todo', 'new', 'queued'].includes(normalized)) {
98
+ return 'pending';
99
+ }
100
+
101
+ return 'unknown';
102
+ }
103
+
104
+ function normalizeTimestamp(value) {
105
+ if (!value) {
106
+ return undefined;
107
+ }
108
+
109
+ const parsed = new Date(value);
110
+ if (Number.isNaN(parsed.getTime())) {
111
+ return undefined;
112
+ }
113
+
114
+ return parsed.toISOString();
115
+ }
116
+
117
+ function parseIntentFolderName(folderName) {
118
+ const match = String(folderName).match(/^(\d{3})-(.+)$/);
119
+ if (!match) {
120
+ return null;
121
+ }
122
+
123
+ return {
124
+ number: match[1],
125
+ name: match[2]
126
+ };
127
+ }
128
+
129
+ function parseStoryFilename(filename) {
130
+ const match = String(filename).match(/^(\d{3})-(.+)\.md$/);
131
+ if (!match) {
132
+ return {
133
+ id: path.basename(filename, '.md'),
134
+ title: path.basename(filename, '.md')
135
+ };
136
+ }
137
+
138
+ return {
139
+ id: match[1],
140
+ title: match[2]
141
+ };
142
+ }
143
+
144
+ function deriveAggregateStatus(statuses) {
145
+ const safeStatuses = Array.isArray(statuses)
146
+ ? statuses.filter((status) => status && status !== 'unknown')
147
+ : [];
148
+
149
+ if (safeStatuses.length === 0) {
150
+ return 'pending';
151
+ }
152
+
153
+ if (safeStatuses.some((status) => status === 'in_progress')) {
154
+ return 'in_progress';
155
+ }
156
+
157
+ if (safeStatuses.every((status) => status === 'completed')) {
158
+ return 'completed';
159
+ }
160
+
161
+ if (safeStatuses.some((status) => status === 'blocked')) {
162
+ return 'blocked';
163
+ }
164
+
165
+ return 'pending';
166
+ }
167
+
168
+ function countByStatus(items) {
169
+ return (items || []).reduce((acc, item) => {
170
+ const status = item?.status || 'unknown';
171
+ if (status === 'completed') acc.completed += 1;
172
+ else if (status === 'in_progress') acc.inProgress += 1;
173
+ else if (status === 'blocked') acc.blocked += 1;
174
+ else if (status === 'pending') acc.pending += 1;
175
+ else acc.unknown += 1;
176
+ return acc;
177
+ }, {
178
+ completed: 0,
179
+ inProgress: 0,
180
+ pending: 0,
181
+ blocked: 0,
182
+ unknown: 0
183
+ });
184
+ }
185
+
186
+ function parseStory(storyPath, unitId, intentId) {
187
+ const fileName = path.basename(storyPath);
188
+ const parsedName = parseStoryFilename(fileName);
189
+ const frontmatter = parseFrontmatter(readFileSafe(storyPath) || '');
190
+
191
+ return {
192
+ id: parsedName.id,
193
+ title: typeof frontmatter.title === 'string' ? frontmatter.title : parsedName.title,
194
+ unitId,
195
+ intentId,
196
+ path: storyPath,
197
+ status: normalizeStatus(frontmatter.status)
198
+ };
199
+ }
200
+
201
+ function parseUnit(unitPath, intentId) {
202
+ const unitId = path.basename(unitPath);
203
+ const unitBriefPath = path.join(unitPath, 'unit-brief.md');
204
+ const unitBriefFrontmatter = parseFrontmatter(readFileSafe(unitBriefPath) || '');
205
+
206
+ const storiesPath = path.join(unitPath, 'stories');
207
+ const storyFiles = listMarkdownFiles(storiesPath);
208
+ const stories = storyFiles
209
+ .map((storyFile) => parseStory(path.join(storiesPath, storyFile), unitId, intentId))
210
+ .sort((a, b) => a.id.localeCompare(b.id));
211
+
212
+ const derivedStatus = deriveAggregateStatus(stories.map((story) => story.status));
213
+ const statusFromFrontmatter = normalizeStatus(unitBriefFrontmatter.status);
214
+ const status = statusFromFrontmatter !== 'unknown' ? statusFromFrontmatter : derivedStatus;
215
+
216
+ const storyStats = countByStatus(stories);
217
+
218
+ return {
219
+ id: unitId,
220
+ intentId,
221
+ path: unitPath,
222
+ status,
223
+ stories,
224
+ storyCount: stories.length,
225
+ completedStories: storyStats.completed,
226
+ inProgressStories: storyStats.inProgress,
227
+ pendingStories: storyStats.pending,
228
+ blockedStories: storyStats.blocked
229
+ };
230
+ }
231
+
232
+ function parseIntent(intentPath, warnings) {
233
+ const folderName = path.basename(intentPath);
234
+ const parsedName = parseIntentFolderName(folderName);
235
+
236
+ if (!parsedName) {
237
+ warnings.push(`Intent folder ${folderName} does not match expected format NNN-name.`);
238
+ return null;
239
+ }
240
+
241
+ const requirementsPath = path.join(intentPath, 'requirements.md');
242
+ const requirementsFrontmatter = parseFrontmatter(readFileSafe(requirementsPath) || '');
243
+
244
+ const unitsPath = path.join(intentPath, 'units');
245
+ const unitFolders = listSubdirectories(unitsPath);
246
+ const units = unitFolders
247
+ .map((unitFolder) => parseUnit(path.join(unitsPath, unitFolder), folderName))
248
+ .sort((a, b) => a.id.localeCompare(b.id));
249
+
250
+ const derivedStatus = deriveAggregateStatus(units.map((unit) => unit.status));
251
+ const statusFromFrontmatter = normalizeStatus(requirementsFrontmatter.status);
252
+ const status = statusFromFrontmatter !== 'unknown' ? statusFromFrontmatter : derivedStatus;
253
+
254
+ const unitStats = countByStatus(units);
255
+ const stories = units.flatMap((unit) => unit.stories);
256
+ const storyStats = countByStatus(stories);
257
+
258
+ return {
259
+ id: folderName,
260
+ number: parsedName.number,
261
+ name: parsedName.name,
262
+ title: `${parsedName.number}-${parsedName.name}`,
263
+ path: intentPath,
264
+ status,
265
+ units,
266
+ unitCount: units.length,
267
+ storyCount: stories.length,
268
+ completedUnits: unitStats.completed,
269
+ inProgressUnits: unitStats.inProgress,
270
+ pendingUnits: unitStats.pending,
271
+ blockedUnits: unitStats.blocked,
272
+ completedStories: storyStats.completed,
273
+ inProgressStories: storyStats.inProgress,
274
+ pendingStories: storyStats.pending,
275
+ blockedStories: storyStats.blocked
276
+ };
277
+ }
278
+
279
+ function parseStageNames(type) {
280
+ return DEFAULT_BOLT_STAGE_MAP[type] || DEFAULT_BOLT_STAGE_MAP['simple-construction-bolt'];
281
+ }
282
+
283
+ function extractStageNamesFromFrontmatter(rawStagesCompleted) {
284
+ if (!Array.isArray(rawStagesCompleted)) {
285
+ return [];
286
+ }
287
+
288
+ return rawStagesCompleted
289
+ .map((stage) => {
290
+ if (typeof stage === 'string') {
291
+ return stage;
292
+ }
293
+ if (stage && typeof stage === 'object' && typeof stage.name === 'string') {
294
+ return stage.name;
295
+ }
296
+ return null;
297
+ })
298
+ .filter(Boolean)
299
+ .map((stage) => String(stage).toLowerCase());
300
+ }
301
+
302
+ function parseBolt(boltPath, warnings) {
303
+ const boltId = path.basename(boltPath);
304
+ const boltFilePath = path.join(boltPath, 'bolt.md');
305
+
306
+ const content = readFileSafe(boltFilePath);
307
+ if (!content) {
308
+ warnings.push(`Bolt ${boltId} is missing bolt.md.`);
309
+ return null;
310
+ }
311
+
312
+ const frontmatter = parseFrontmatter(content);
313
+ const type = typeof frontmatter.type === 'string' ? frontmatter.type : 'simple-construction-bolt';
314
+ const currentStage = typeof frontmatter.current_stage === 'string'
315
+ ? frontmatter.current_stage
316
+ : (typeof frontmatter.currentStage === 'string' ? frontmatter.currentStage : null);
317
+
318
+ const stageNames = parseStageNames(type);
319
+ const completedStageNames = extractStageNamesFromFrontmatter(frontmatter.stages_completed);
320
+ const normalizedCurrentStage = currentStage ? currentStage.toLowerCase() : null;
321
+
322
+ const stages = stageNames.map((stageName, index) => {
323
+ const normalizedStageName = stageName.toLowerCase();
324
+ const status = completedStageNames.includes(normalizedStageName)
325
+ ? 'completed'
326
+ : (normalizedCurrentStage === normalizedStageName ? 'in_progress' : 'pending');
327
+
328
+ return {
329
+ name: stageName,
330
+ order: index + 1,
331
+ status
332
+ };
333
+ });
334
+
335
+ let status = normalizeStatus(frontmatter.status);
336
+ if (status === 'unknown') {
337
+ if (stages.length > 0 && stages.every((stage) => stage.status === 'completed')) {
338
+ status = 'completed';
339
+ } else if (normalizedCurrentStage) {
340
+ status = 'in_progress';
341
+ } else {
342
+ status = 'pending';
343
+ }
344
+ }
345
+
346
+ const markdownFiles = listMarkdownFiles(boltPath);
347
+
348
+ return {
349
+ id: boltId,
350
+ intent: typeof frontmatter.intent === 'string' ? frontmatter.intent : '',
351
+ unit: typeof frontmatter.unit === 'string' ? frontmatter.unit : '',
352
+ type,
353
+ status,
354
+ currentStage,
355
+ stages,
356
+ stagesCompleted: completedStageNames,
357
+ stories: Array.isArray(frontmatter.stories) ? frontmatter.stories.filter((value) => typeof value === 'string') : [],
358
+ path: boltPath,
359
+ filePath: boltFilePath,
360
+ files: markdownFiles,
361
+ requiresBolts: Array.isArray(frontmatter.requires_bolts)
362
+ ? frontmatter.requires_bolts.filter((value) => typeof value === 'string')
363
+ : [],
364
+ enablesBolts: Array.isArray(frontmatter.enables_bolts)
365
+ ? frontmatter.enables_bolts.filter((value) => typeof value === 'string')
366
+ : [],
367
+ isBlocked: false,
368
+ blockedBy: [],
369
+ unblocksCount: 0,
370
+ createdAt: normalizeTimestamp(frontmatter.created),
371
+ startedAt: normalizeTimestamp(frontmatter.started),
372
+ completedAt: normalizeTimestamp(frontmatter.completed)
373
+ };
374
+ }
375
+
376
+ function computeBoltDependencyState(bolts, warnings) {
377
+ const byId = new Map((bolts || []).map((bolt) => [bolt.id, bolt]));
378
+
379
+ for (const bolt of bolts || []) {
380
+ const blockedBy = [];
381
+
382
+ for (const requiredBoltId of bolt.requiresBolts || []) {
383
+ const requiredBolt = byId.get(requiredBoltId);
384
+ if (!requiredBolt) {
385
+ blockedBy.push(requiredBoltId);
386
+ warnings.push(`Bolt ${bolt.id} depends on missing bolt ${requiredBoltId}.`);
387
+ continue;
388
+ }
389
+
390
+ if (requiredBolt.status !== 'completed') {
391
+ blockedBy.push(requiredBoltId);
392
+ }
393
+ }
394
+
395
+ bolt.blockedBy = blockedBy;
396
+ bolt.isBlocked = blockedBy.length > 0;
397
+
398
+ if (bolt.status === 'pending' && bolt.isBlocked) {
399
+ bolt.status = 'blocked';
400
+ }
401
+ }
402
+
403
+ for (const bolt of bolts || []) {
404
+ bolt.unblocksCount = (bolts || []).filter((candidate) =>
405
+ candidate.id !== bolt.id && (candidate.requiresBolts || []).includes(bolt.id) && candidate.status !== 'completed'
406
+ ).length;
407
+ }
408
+ }
409
+
410
+ function buildProjectMetadata(workspacePath, rootPath) {
411
+ const fallbackName = path.basename(workspacePath);
412
+ const config = parseYamlFile(path.join(rootPath, 'project.yaml')) || {};
413
+
414
+ return {
415
+ name: typeof config.name === 'string' && config.name.trim() !== ''
416
+ ? config.name
417
+ : fallbackName,
418
+ description: typeof config.description === 'string' ? config.description : undefined,
419
+ projectType: typeof config.project_type === 'string'
420
+ ? config.project_type
421
+ : (typeof config.projectType === 'string' ? config.projectType : undefined)
422
+ };
423
+ }
424
+
425
+ function buildStats(intents, units, stories, bolts) {
426
+ const intentStats = countByStatus(intents);
427
+ const unitStats = countByStatus(units);
428
+ const storyStats = countByStatus(stories);
429
+
430
+ const activeBolts = bolts.filter((bolt) => bolt.status === 'in_progress');
431
+ const completedBolts = bolts.filter((bolt) => bolt.status === 'completed');
432
+ const blockedBolts = bolts.filter((bolt) => bolt.status === 'blocked' || bolt.isBlocked);
433
+ const queuedBolts = bolts.filter((bolt) => bolt.status === 'pending' && !bolt.isBlocked);
434
+
435
+ const totalStories = stories.length;
436
+ const completedStories = storyStats.completed;
437
+ const progressPercent = totalStories > 0
438
+ ? Math.round((completedStories / totalStories) * 100)
439
+ : 0;
440
+
441
+ return {
442
+ totalIntents: intents.length,
443
+ completedIntents: intentStats.completed,
444
+ inProgressIntents: intentStats.inProgress,
445
+ pendingIntents: intentStats.pending,
446
+ blockedIntents: intentStats.blocked,
447
+ totalUnits: units.length,
448
+ completedUnits: unitStats.completed,
449
+ inProgressUnits: unitStats.inProgress,
450
+ pendingUnits: unitStats.pending,
451
+ blockedUnits: unitStats.blocked,
452
+ totalStories: stories.length,
453
+ completedStories: storyStats.completed,
454
+ inProgressStories: storyStats.inProgress,
455
+ pendingStories: storyStats.pending,
456
+ blockedStories: storyStats.blocked,
457
+ totalBolts: bolts.length,
458
+ activeBoltsCount: activeBolts.length,
459
+ queuedBolts: queuedBolts.length,
460
+ blockedBolts: blockedBolts.length,
461
+ completedBolts: completedBolts.length,
462
+ progressPercent
463
+ };
464
+ }
465
+
466
+ function parseAidlcDashboard(workspacePath) {
467
+ const rootPath = path.join(workspacePath, 'memory-bank');
468
+
469
+ if (!fs.existsSync(rootPath) || !fs.statSync(rootPath).isDirectory()) {
470
+ return {
471
+ ok: false,
472
+ error: {
473
+ code: 'AIDLC_NOT_FOUND',
474
+ message: `No AI-DLC workspace found at ${rootPath}`,
475
+ hint: 'Run this command from a workspace containing memory-bank/ or choose --flow fire/simple.'
476
+ }
477
+ };
478
+ }
479
+
480
+ const warnings = [];
481
+ const intentsPath = path.join(rootPath, 'intents');
482
+ const boltsPath = path.join(rootPath, 'bolts');
483
+ const standardsPath = path.join(rootPath, 'standards');
484
+
485
+ const intentFolders = listSubdirectories(intentsPath);
486
+ const intents = intentFolders
487
+ .map((intentFolder) => parseIntent(path.join(intentsPath, intentFolder), warnings))
488
+ .filter(Boolean)
489
+ .sort((a, b) => a.id.localeCompare(b.id));
490
+
491
+ if (intentFolders.length === 0) {
492
+ warnings.push('No intents found under memory-bank/intents.');
493
+ }
494
+
495
+ const units = intents.flatMap((intent) => intent.units || []);
496
+ const stories = units.flatMap((unit) => unit.stories || []);
497
+
498
+ const boltFolders = listSubdirectories(boltsPath);
499
+ const bolts = boltFolders
500
+ .map((boltFolder) => parseBolt(path.join(boltsPath, boltFolder), warnings))
501
+ .filter(Boolean);
502
+
503
+ computeBoltDependencyState(bolts, warnings);
504
+
505
+ const activeBolts = bolts
506
+ .filter((bolt) => bolt.status === 'in_progress')
507
+ .sort((a, b) => {
508
+ const aTime = a.startedAt ? Date.parse(a.startedAt) : 0;
509
+ const bTime = b.startedAt ? Date.parse(b.startedAt) : 0;
510
+ if (bTime !== aTime) {
511
+ return bTime - aTime;
512
+ }
513
+ return a.id.localeCompare(b.id);
514
+ });
515
+
516
+ const pendingBolts = bolts
517
+ .filter((bolt) => bolt.status === 'pending' || bolt.status === 'blocked' || bolt.isBlocked)
518
+ .sort((a, b) => {
519
+ const aScore = a.isBlocked ? 1 : 0;
520
+ const bScore = b.isBlocked ? 1 : 0;
521
+ if (aScore !== bScore) {
522
+ return aScore - bScore;
523
+ }
524
+ if (b.unblocksCount !== a.unblocksCount) {
525
+ return b.unblocksCount - a.unblocksCount;
526
+ }
527
+ return a.id.localeCompare(b.id);
528
+ });
529
+
530
+ const completedBolts = bolts
531
+ .filter((bolt) => bolt.status === 'completed')
532
+ .sort((a, b) => {
533
+ const aTime = a.completedAt ? Date.parse(a.completedAt) : 0;
534
+ const bTime = b.completedAt ? Date.parse(b.completedAt) : 0;
535
+ if (bTime !== aTime) {
536
+ return bTime - aTime;
537
+ }
538
+ return b.id.localeCompare(a.id);
539
+ });
540
+
541
+ const standards = listMarkdownFiles(standardsPath)
542
+ .map((fileName) => ({
543
+ name: path.basename(fileName, '.md'),
544
+ type: path.basename(fileName, '.md'),
545
+ filePath: path.join(standardsPath, fileName)
546
+ }));
547
+
548
+ const project = buildProjectMetadata(workspacePath, rootPath);
549
+ const stats = buildStats(intents, units, stories, bolts);
550
+
551
+ return {
552
+ ok: true,
553
+ snapshot: {
554
+ flow: 'aidlc',
555
+ isProject: true,
556
+ initialized: true,
557
+ workspacePath,
558
+ rootPath,
559
+ version: '1.0.0',
560
+ project,
561
+ intents,
562
+ units,
563
+ stories,
564
+ bolts,
565
+ activeBolts,
566
+ pendingBolts,
567
+ completedBolts,
568
+ standards,
569
+ stats,
570
+ warnings,
571
+ generatedAt: new Date().toISOString()
572
+ }
573
+ };
574
+ }
575
+
576
+ module.exports = {
577
+ DEFAULT_BOLT_STAGE_MAP,
578
+ parseFrontmatter,
579
+ normalizeStatus,
580
+ parseAidlcDashboard
581
+ };