@polymorphism-tech/morph-spec 7.2.0 → 7.4.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/CLAUDE.md +1 -1
- package/README.md +2 -2
- package/bin/morph-spec.js +4 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -2
- package/framework/MORPH.md +29 -12
- package/framework/agents.json +132 -132
- package/framework/commands/morph-apply.md +1 -1
- package/framework/commands/morph-proposal.md +1 -1
- package/framework/evals/rubrics/README.md +9 -8
- package/framework/hooks/README.md +3 -1
- package/framework/hooks/claude-code/core/gate-guard.js +160 -0
- package/framework/hooks/claude-code/core/state-sync.js +15 -3
- package/framework/hooks/claude-code/core/validate-completion.js +241 -136
- package/framework/hooks/shared/state-reader.js +236 -221
- package/framework/phases.json +1 -1
- package/framework/skills/README.md +2 -2
- package/framework/skills/core/morph-apply/SKILL.md +63 -21
- package/framework/skills/core/morph-brainstorming/SKILL.md +5 -1
- package/framework/skills/core/morph-eval/SKILL.md +4 -5
- package/framework/skills/core/morph-implement/SKILL.md +47 -19
- package/framework/skills/core/morph-plan/SKILL.md +8 -2
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +13 -6
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/standards/ai-agents/setup.md +258 -244
- package/framework/templates/README.md +708 -709
- package/framework/templates/REGISTRY.json +1 -20
- package/framework/templates/context/CONTEXT-FEATURE.md +276 -276
- package/framework/templates/docs/proposal.md +171 -171
- package/framework/templates/feature/recap.md +145 -145
- package/package.json +1 -1
- package/src/commands/project/doctor.js +941 -946
- package/src/commands/project/init.js +517 -515
- package/src/commands/project/status.js +373 -376
- package/src/commands/state/approve.js +251 -213
- package/src/commands/validation/validate-feature.js +356 -287
- package/src/core/state/state-manager.js +910 -934
- package/src/dashboard/app/App.jsx +29 -31
- package/src/dashboard/app/components/Sidebar.jsx +44 -45
- package/src/dashboard/scanner/index.js +20 -21
- package/src/lib/context/context-builder.js +3 -3
- package/src/lib/generators/context-generator.js +2 -3
- package/src/lib/generators/mandate-generator.js +48 -92
- package/src/lib/generators/metadata-extractor.js +385 -386
- package/src/lib/generators/recap-generator.js +285 -285
- package/src/lib/risk-detector.js +2 -2
- package/src/lib/validators/content/content-validator.js +89 -286
- package/src/lib/validators/content/index.js +0 -1
- package/src/utils/hooks-installer.js +527 -516
- package/framework/templates/feature/tasks.md +0 -199
- package/framework/workflows/README.md +0 -1041
- package/framework/workflows/configs/fast-track.json +0 -46
- package/framework/workflows/configs/full.json +0 -64
- package/framework/workflows/configs/quick.json +0 -32
- package/framework/workflows/configs/standard.json +0 -57
- package/framework/workflows/docs/STORY-DRIVEN-DEVELOPMENT.md +0 -392
- package/framework/workflows/docs/design-impl.md +0 -37
- package/framework/workflows/docs/enforcement-pipeline.md +0 -668
- package/framework/workflows/docs/fast-track.md +0 -29
- package/framework/workflows/docs/full-morph.md +0 -69
- package/framework/workflows/docs/standard.md +0 -49
- package/framework/workflows/docs/ui-refresh.md +0 -39
- package/src/commands/state/index.js +0 -7
- package/src/core/workflows/workflow-detector.js +0 -586
- package/src/core/workflows/workflow-router.js +0 -204
- package/src/dashboard/app/pages/Workflows.jsx +0 -346
- package/src/dashboard/scanner/workflows-scanner.js +0 -18
- package/src/lib/phase-chain/phase-validator.js +0 -359
- package/src/lib/validators/content/cross-phase-validator.js +0 -281
- package/src/lib/validators/spec-validator.js +0 -312
|
@@ -1,386 +1,385 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'fs';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Metadata Extractor - Parse markdown documents into structured JSON
|
|
5
|
-
*
|
|
6
|
-
* Generates quick summaries for LLM to parse, reducing token usage
|
|
7
|
-
* by 60-80% compared to reading full markdown files.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Extract a field value from markdown content (table or colon format)
|
|
12
|
-
* @param {string} content - Markdown content
|
|
13
|
-
* @param {string} fieldName - Field name to extract
|
|
14
|
-
* @returns {string|null} Field value or null
|
|
15
|
-
*/
|
|
16
|
-
function extractField(content, fieldName) {
|
|
17
|
-
// Try table row format first: | FieldName | Value |
|
|
18
|
-
const tableRegex = new RegExp(`\\|\\s*${fieldName}\\s*\\|\\s*(.+?)\\s*\\|`, 'i');
|
|
19
|
-
const tableMatch = content.match(tableRegex);
|
|
20
|
-
if (tableMatch) {
|
|
21
|
-
return tableMatch[1].trim();
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// Try colon format: FieldName: Value
|
|
25
|
-
const colonRegex = new RegExp(`^${fieldName}\\s*:\\s*(.+)$`, 'im');
|
|
26
|
-
const colonMatch = content.match(colonRegex);
|
|
27
|
-
return colonMatch ? colonMatch[1].trim() : null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Extract first paragraph from a section
|
|
32
|
-
* @param {string} content - Markdown content
|
|
33
|
-
* @param {string} sectionHeader - Section header (e.g., "## Overview")
|
|
34
|
-
* @returns {string|null} First paragraph or null
|
|
35
|
-
*/
|
|
36
|
-
function extractFirstParagraph(content, sectionHeader) {
|
|
37
|
-
const regex = new RegExp(`${sectionHeader}\\s+([^\\n]+(?:\\n(?!##)[^\\n]+)*)`, 'i');
|
|
38
|
-
const match = content.match(regex);
|
|
39
|
-
|
|
40
|
-
if (!match) return null;
|
|
41
|
-
|
|
42
|
-
// Get first paragraph (up to first blank line or next section)
|
|
43
|
-
const text = match[1].trim();
|
|
44
|
-
const firstPara = text.split('\n\n')[0];
|
|
45
|
-
return firstPara.substring(0, 300); // Limit to 300 chars
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Extract bullet points from a section
|
|
50
|
-
* @param {string} content - Markdown content
|
|
51
|
-
* @param {string} sectionHeader - Section header
|
|
52
|
-
* @returns {string[]} Array of bullet points
|
|
53
|
-
*/
|
|
54
|
-
function extractBulletPoints(content, sectionHeader) {
|
|
55
|
-
const regex = new RegExp(`${sectionHeader}([\\s\\S]*?)(?=\\n## |$)`, 'i');
|
|
56
|
-
const match = content.match(regex);
|
|
57
|
-
|
|
58
|
-
if (!match) return [];
|
|
59
|
-
|
|
60
|
-
const sectionContent = match[1];
|
|
61
|
-
const bullets = [];
|
|
62
|
-
|
|
63
|
-
// Match lines starting with -, *, or numbered list
|
|
64
|
-
const bulletRegex = /^[\s]*[-*][\s]+(.+)$/gm;
|
|
65
|
-
let bulletMatch;
|
|
66
|
-
|
|
67
|
-
while ((bulletMatch = bulletRegex.exec(sectionContent)) !== null) {
|
|
68
|
-
bullets.push(bulletMatch[1].trim());
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return bullets.slice(0, 10); // Limit to 10 items
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Extract table or list from a section
|
|
76
|
-
* @param {string} content - Markdown content
|
|
77
|
-
* @param {string} sectionHeader - Section header
|
|
78
|
-
* @returns {string[]} Array of items
|
|
79
|
-
*/
|
|
80
|
-
function extractTableOrList(content, sectionHeader) {
|
|
81
|
-
const items = extractBulletPoints(content, sectionHeader);
|
|
82
|
-
|
|
83
|
-
if (items.length > 0) return items;
|
|
84
|
-
|
|
85
|
-
// Try extracting table rows
|
|
86
|
-
const regex = new RegExp(`${sectionHeader}([\\s\\S]*?)(?=\\n## |$)`, 'i');
|
|
87
|
-
const match = content.match(regex);
|
|
88
|
-
|
|
89
|
-
if (!match) return [];
|
|
90
|
-
|
|
91
|
-
const sectionContent = match[1];
|
|
92
|
-
const tableRows = [];
|
|
93
|
-
|
|
94
|
-
// Match table rows (| col1 | col2 |)
|
|
95
|
-
const rowRegex = /^\|(.+)\|$/gm;
|
|
96
|
-
let rowMatch;
|
|
97
|
-
|
|
98
|
-
while ((rowMatch = rowRegex.exec(sectionContent)) !== null) {
|
|
99
|
-
const row = rowMatch[1].trim();
|
|
100
|
-
// Skip header separator rows
|
|
101
|
-
if (!row.match(/^[-:|\s]+$/)) {
|
|
102
|
-
tableRows.push(row);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return tableRows.slice(0, 10); // Limit to 10 rows
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Auto-generate tags from content
|
|
111
|
-
* @param {string} content - Markdown content
|
|
112
|
-
* @returns {string[]} Array of tags
|
|
113
|
-
*/
|
|
114
|
-
function autoGenerateTags(content) {
|
|
115
|
-
const tags = new Set();
|
|
116
|
-
const lowerContent = content.toLowerCase();
|
|
117
|
-
|
|
118
|
-
// Technology tags
|
|
119
|
-
const techKeywords = {
|
|
120
|
-
'react': 'react',
|
|
121
|
-
'nextjs': 'nextjs',
|
|
122
|
-
'next.js': 'nextjs',
|
|
123
|
-
'azure': 'azure',
|
|
124
|
-
'sql': 'database',
|
|
125
|
-
'entity framework': 'ef-core',
|
|
126
|
-
'hangfire': 'background-jobs',
|
|
127
|
-
'signalr': 'realtime',
|
|
128
|
-
'docker': 'container',
|
|
129
|
-
'bicep': 'infrastructure-as-code',
|
|
130
|
-
'api': 'api',
|
|
131
|
-
'rest': 'rest-api',
|
|
132
|
-
'authentication': 'auth',
|
|
133
|
-
'authorization': 'auth'
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
Object.entries(techKeywords).forEach(([keyword, tag]) => {
|
|
137
|
-
if (lowerContent.includes(keyword)) {
|
|
138
|
-
tags.add(tag);
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
return Array.from(tags).slice(0, 8); // Limit to 8 tags
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Extract spec.md metadata
|
|
147
|
-
* @param {string} specPath - Path to spec.md file
|
|
148
|
-
* @returns {Object} Spec metadata
|
|
149
|
-
*/
|
|
150
|
-
export function extractSpecMetadata(specPath) {
|
|
151
|
-
if (!existsSync(specPath)) {
|
|
152
|
-
return {
|
|
153
|
-
error: 'Spec file not found',
|
|
154
|
-
path: specPath
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
try {
|
|
159
|
-
const content = readFileSync(specPath, 'utf8');
|
|
160
|
-
|
|
161
|
-
return {
|
|
162
|
-
id: extractField(content, 'ID') || extractField(content, 'Feature'),
|
|
163
|
-
status: extractField(content, 'Status'),
|
|
164
|
-
complexity: extractField(content, 'Complexity'),
|
|
165
|
-
estimatedCost: extractField(content, 'Estimated Cost') || extractField(content, 'Cost'),
|
|
166
|
-
|
|
167
|
-
summary: {
|
|
168
|
-
problem: extractFirstParagraph(content, '## Overview') ||
|
|
169
|
-
extractFirstParagraph(content, '## Problem Statement'),
|
|
170
|
-
solution: extractFirstParagraph(content, '## Proposed Solution') ||
|
|
171
|
-
extractFirstParagraph(content, '## Solution'),
|
|
172
|
-
tags: autoGenerateTags(content)
|
|
173
|
-
},
|
|
174
|
-
|
|
175
|
-
requirements: extractBulletPoints(content, '## Requirements').slice(0, 5),
|
|
176
|
-
dataModel: extractTableOrList(content, '## Data Model').slice(0, 5),
|
|
177
|
-
apiContracts: extractTableOrList(content, '## API Contracts').slice(0, 5),
|
|
178
|
-
|
|
179
|
-
stats: {
|
|
180
|
-
length: content.length,
|
|
181
|
-
sections: (content.match(/^## /gm) || []).length,
|
|
182
|
-
codeBlocks: (content.match(/```/g) || []).length / 2
|
|
183
|
-
},
|
|
184
|
-
|
|
185
|
-
generatedAt: new Date().toISOString(),
|
|
186
|
-
source: specPath
|
|
187
|
-
};
|
|
188
|
-
} catch (error) {
|
|
189
|
-
return {
|
|
190
|
-
error: error.message,
|
|
191
|
-
path: specPath
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Extract proposal.md metadata
|
|
198
|
-
* @param {string} proposalPath - Path to proposal.md file
|
|
199
|
-
* @returns {Object} Proposal metadata
|
|
200
|
-
*/
|
|
201
|
-
export function extractProposalMetadata(proposalPath) {
|
|
202
|
-
if (!existsSync(proposalPath)) {
|
|
203
|
-
return {
|
|
204
|
-
error: 'Proposal file not found',
|
|
205
|
-
path: proposalPath
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
try {
|
|
210
|
-
const content = readFileSync(proposalPath, 'utf8');
|
|
211
|
-
|
|
212
|
-
return {
|
|
213
|
-
id: extractField(content, 'ID') || extractField(content, 'Feature'),
|
|
214
|
-
status: extractField(content, 'Status'),
|
|
215
|
-
|
|
216
|
-
summary: {
|
|
217
|
-
problem: extractFirstParagraph(content, '## Problem Statement'),
|
|
218
|
-
solution: extractFirstParagraph(content, '## Proposed Solution'),
|
|
219
|
-
tags: autoGenerateTags(content)
|
|
220
|
-
},
|
|
221
|
-
|
|
222
|
-
successMetrics: extractBulletPoints(content, '## Success Metrics').slice(0, 5),
|
|
223
|
-
|
|
224
|
-
generatedAt: new Date().toISOString(),
|
|
225
|
-
source: proposalPath
|
|
226
|
-
};
|
|
227
|
-
} catch (error) {
|
|
228
|
-
return {
|
|
229
|
-
error: error.message,
|
|
230
|
-
path: proposalPath
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Extract decisions.md metadata (ADRs)
|
|
237
|
-
* @param {string} decisionsPath - Path to decisions.md file
|
|
238
|
-
* @returns {Object} Decisions metadata
|
|
239
|
-
*/
|
|
240
|
-
export function extractDecisionsMetadata(decisionsPath) {
|
|
241
|
-
if (!existsSync(decisionsPath)) {
|
|
242
|
-
return {
|
|
243
|
-
error: 'Decisions file not found',
|
|
244
|
-
path: decisionsPath
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
try {
|
|
249
|
-
const content = readFileSync(decisionsPath, 'utf8');
|
|
250
|
-
const decisions = [];
|
|
251
|
-
|
|
252
|
-
// Match ADR pattern: ### ADR-001: Title
|
|
253
|
-
const adrRegex = /### (ADR-\d+): (.+?)\n([\s\S]*?)(?=### ADR-|\n## |$)/g;
|
|
254
|
-
let match;
|
|
255
|
-
|
|
256
|
-
while ((match = adrRegex.exec(content)) !== null) {
|
|
257
|
-
const [, id, title, body] = match;
|
|
258
|
-
|
|
259
|
-
decisions.push({
|
|
260
|
-
id,
|
|
261
|
-
title: title.trim(),
|
|
262
|
-
summary: body.trim().substring(0, 200), // First 200 chars
|
|
263
|
-
fullPath: `${decisionsPath}#${id.toLowerCase()}`
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
return {
|
|
268
|
-
decisions,
|
|
269
|
-
total: decisions.length,
|
|
270
|
-
generatedAt: new Date().toISOString(),
|
|
271
|
-
source: decisionsPath
|
|
272
|
-
};
|
|
273
|
-
} catch (error) {
|
|
274
|
-
return {
|
|
275
|
-
error: error.message,
|
|
276
|
-
path: decisionsPath
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Extract tasks.json metadata (summary only)
|
|
283
|
-
* @param {string} tasksPath - Path to tasks.json file
|
|
284
|
-
* @returns {Object} Tasks metadata
|
|
285
|
-
*/
|
|
286
|
-
export function extractTasksMetadata(tasksPath) {
|
|
287
|
-
if (!existsSync(tasksPath)) {
|
|
288
|
-
return {
|
|
289
|
-
error: 'Tasks file not found',
|
|
290
|
-
path: tasksPath
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
try {
|
|
295
|
-
const content = readFileSync(tasksPath, 'utf8');
|
|
296
|
-
const tasks = JSON.parse(content);
|
|
297
|
-
|
|
298
|
-
const regularTasks = tasks.tasks?.filter(t => t.id?.startsWith('T')) || [];
|
|
299
|
-
const checkpoints = tasks.tasks?.filter(t => t.id?.startsWith('CHECKPOINT')) || [];
|
|
300
|
-
|
|
301
|
-
return {
|
|
302
|
-
total: tasks.tasks?.length || 0,
|
|
303
|
-
regularTasks: regularTasks.length,
|
|
304
|
-
checkpoints: checkpoints.length,
|
|
305
|
-
|
|
306
|
-
categories: tasks.categories || {},
|
|
307
|
-
|
|
308
|
-
byStatus: {
|
|
309
|
-
pending: regularTasks.filter(t => t.status === 'pending').length,
|
|
310
|
-
inProgress: regularTasks.filter(t => t.status === 'in_progress').length,
|
|
311
|
-
completed: regularTasks.filter(t => t.status === 'completed').length,
|
|
312
|
-
blocked: regularTasks.filter(t => t.status === 'blocked').length
|
|
313
|
-
},
|
|
314
|
-
|
|
315
|
-
estimatedTime: regularTasks.reduce((sum, t) => sum + (t.estimatedMinutes || 0), 0),
|
|
316
|
-
|
|
317
|
-
nextTask: regularTasks.find(t => t.status === 'pending' &&
|
|
318
|
-
(!t.dependencies || t.dependencies.length === 0)),
|
|
319
|
-
|
|
320
|
-
generatedAt: new Date().toISOString(),
|
|
321
|
-
source: tasksPath
|
|
322
|
-
};
|
|
323
|
-
} catch (error) {
|
|
324
|
-
return {
|
|
325
|
-
error: error.message,
|
|
326
|
-
path: tasksPath
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Extract comprehensive feature metadata
|
|
333
|
-
* @param {Object} featureState - Feature state from state.json
|
|
334
|
-
* @returns {Object} Complete feature metadata
|
|
335
|
-
*/
|
|
336
|
-
export function extractFeatureMetadata(featureState) {
|
|
337
|
-
const metadata = {
|
|
338
|
-
version: "1.0.0",
|
|
339
|
-
feature: featureState.name || 'unknown',
|
|
340
|
-
status: featureState.status,
|
|
341
|
-
phase: featureState.phase,
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
metadata.
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
metadata.
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
metadata.
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
metadata.
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Metadata Extractor - Parse markdown documents into structured JSON
|
|
5
|
+
*
|
|
6
|
+
* Generates quick summaries for LLM to parse, reducing token usage
|
|
7
|
+
* by 60-80% compared to reading full markdown files.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Extract a field value from markdown content (table or colon format)
|
|
12
|
+
* @param {string} content - Markdown content
|
|
13
|
+
* @param {string} fieldName - Field name to extract
|
|
14
|
+
* @returns {string|null} Field value or null
|
|
15
|
+
*/
|
|
16
|
+
function extractField(content, fieldName) {
|
|
17
|
+
// Try table row format first: | FieldName | Value |
|
|
18
|
+
const tableRegex = new RegExp(`\\|\\s*${fieldName}\\s*\\|\\s*(.+?)\\s*\\|`, 'i');
|
|
19
|
+
const tableMatch = content.match(tableRegex);
|
|
20
|
+
if (tableMatch) {
|
|
21
|
+
return tableMatch[1].trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Try colon format: FieldName: Value
|
|
25
|
+
const colonRegex = new RegExp(`^${fieldName}\\s*:\\s*(.+)$`, 'im');
|
|
26
|
+
const colonMatch = content.match(colonRegex);
|
|
27
|
+
return colonMatch ? colonMatch[1].trim() : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extract first paragraph from a section
|
|
32
|
+
* @param {string} content - Markdown content
|
|
33
|
+
* @param {string} sectionHeader - Section header (e.g., "## Overview")
|
|
34
|
+
* @returns {string|null} First paragraph or null
|
|
35
|
+
*/
|
|
36
|
+
function extractFirstParagraph(content, sectionHeader) {
|
|
37
|
+
const regex = new RegExp(`${sectionHeader}\\s+([^\\n]+(?:\\n(?!##)[^\\n]+)*)`, 'i');
|
|
38
|
+
const match = content.match(regex);
|
|
39
|
+
|
|
40
|
+
if (!match) return null;
|
|
41
|
+
|
|
42
|
+
// Get first paragraph (up to first blank line or next section)
|
|
43
|
+
const text = match[1].trim();
|
|
44
|
+
const firstPara = text.split('\n\n')[0];
|
|
45
|
+
return firstPara.substring(0, 300); // Limit to 300 chars
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract bullet points from a section
|
|
50
|
+
* @param {string} content - Markdown content
|
|
51
|
+
* @param {string} sectionHeader - Section header
|
|
52
|
+
* @returns {string[]} Array of bullet points
|
|
53
|
+
*/
|
|
54
|
+
function extractBulletPoints(content, sectionHeader) {
|
|
55
|
+
const regex = new RegExp(`${sectionHeader}([\\s\\S]*?)(?=\\n## |$)`, 'i');
|
|
56
|
+
const match = content.match(regex);
|
|
57
|
+
|
|
58
|
+
if (!match) return [];
|
|
59
|
+
|
|
60
|
+
const sectionContent = match[1];
|
|
61
|
+
const bullets = [];
|
|
62
|
+
|
|
63
|
+
// Match lines starting with -, *, or numbered list
|
|
64
|
+
const bulletRegex = /^[\s]*[-*][\s]+(.+)$/gm;
|
|
65
|
+
let bulletMatch;
|
|
66
|
+
|
|
67
|
+
while ((bulletMatch = bulletRegex.exec(sectionContent)) !== null) {
|
|
68
|
+
bullets.push(bulletMatch[1].trim());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return bullets.slice(0, 10); // Limit to 10 items
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Extract table or list from a section
|
|
76
|
+
* @param {string} content - Markdown content
|
|
77
|
+
* @param {string} sectionHeader - Section header
|
|
78
|
+
* @returns {string[]} Array of items
|
|
79
|
+
*/
|
|
80
|
+
function extractTableOrList(content, sectionHeader) {
|
|
81
|
+
const items = extractBulletPoints(content, sectionHeader);
|
|
82
|
+
|
|
83
|
+
if (items.length > 0) return items;
|
|
84
|
+
|
|
85
|
+
// Try extracting table rows
|
|
86
|
+
const regex = new RegExp(`${sectionHeader}([\\s\\S]*?)(?=\\n## |$)`, 'i');
|
|
87
|
+
const match = content.match(regex);
|
|
88
|
+
|
|
89
|
+
if (!match) return [];
|
|
90
|
+
|
|
91
|
+
const sectionContent = match[1];
|
|
92
|
+
const tableRows = [];
|
|
93
|
+
|
|
94
|
+
// Match table rows (| col1 | col2 |)
|
|
95
|
+
const rowRegex = /^\|(.+)\|$/gm;
|
|
96
|
+
let rowMatch;
|
|
97
|
+
|
|
98
|
+
while ((rowMatch = rowRegex.exec(sectionContent)) !== null) {
|
|
99
|
+
const row = rowMatch[1].trim();
|
|
100
|
+
// Skip header separator rows
|
|
101
|
+
if (!row.match(/^[-:|\s]+$/)) {
|
|
102
|
+
tableRows.push(row);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return tableRows.slice(0, 10); // Limit to 10 rows
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Auto-generate tags from content
|
|
111
|
+
* @param {string} content - Markdown content
|
|
112
|
+
* @returns {string[]} Array of tags
|
|
113
|
+
*/
|
|
114
|
+
function autoGenerateTags(content) {
|
|
115
|
+
const tags = new Set();
|
|
116
|
+
const lowerContent = content.toLowerCase();
|
|
117
|
+
|
|
118
|
+
// Technology tags
|
|
119
|
+
const techKeywords = {
|
|
120
|
+
'react': 'react',
|
|
121
|
+
'nextjs': 'nextjs',
|
|
122
|
+
'next.js': 'nextjs',
|
|
123
|
+
'azure': 'azure',
|
|
124
|
+
'sql': 'database',
|
|
125
|
+
'entity framework': 'ef-core',
|
|
126
|
+
'hangfire': 'background-jobs',
|
|
127
|
+
'signalr': 'realtime',
|
|
128
|
+
'docker': 'container',
|
|
129
|
+
'bicep': 'infrastructure-as-code',
|
|
130
|
+
'api': 'api',
|
|
131
|
+
'rest': 'rest-api',
|
|
132
|
+
'authentication': 'auth',
|
|
133
|
+
'authorization': 'auth'
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
Object.entries(techKeywords).forEach(([keyword, tag]) => {
|
|
137
|
+
if (lowerContent.includes(keyword)) {
|
|
138
|
+
tags.add(tag);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return Array.from(tags).slice(0, 8); // Limit to 8 tags
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Extract spec.md metadata
|
|
147
|
+
* @param {string} specPath - Path to spec.md file
|
|
148
|
+
* @returns {Object} Spec metadata
|
|
149
|
+
*/
|
|
150
|
+
export function extractSpecMetadata(specPath) {
|
|
151
|
+
if (!existsSync(specPath)) {
|
|
152
|
+
return {
|
|
153
|
+
error: 'Spec file not found',
|
|
154
|
+
path: specPath
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const content = readFileSync(specPath, 'utf8');
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
id: extractField(content, 'ID') || extractField(content, 'Feature'),
|
|
163
|
+
status: extractField(content, 'Status'),
|
|
164
|
+
complexity: extractField(content, 'Complexity'),
|
|
165
|
+
estimatedCost: extractField(content, 'Estimated Cost') || extractField(content, 'Cost'),
|
|
166
|
+
|
|
167
|
+
summary: {
|
|
168
|
+
problem: extractFirstParagraph(content, '## Overview') ||
|
|
169
|
+
extractFirstParagraph(content, '## Problem Statement'),
|
|
170
|
+
solution: extractFirstParagraph(content, '## Proposed Solution') ||
|
|
171
|
+
extractFirstParagraph(content, '## Solution'),
|
|
172
|
+
tags: autoGenerateTags(content)
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
requirements: extractBulletPoints(content, '## Requirements').slice(0, 5),
|
|
176
|
+
dataModel: extractTableOrList(content, '## Data Model').slice(0, 5),
|
|
177
|
+
apiContracts: extractTableOrList(content, '## API Contracts').slice(0, 5),
|
|
178
|
+
|
|
179
|
+
stats: {
|
|
180
|
+
length: content.length,
|
|
181
|
+
sections: (content.match(/^## /gm) || []).length,
|
|
182
|
+
codeBlocks: (content.match(/```/g) || []).length / 2
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
generatedAt: new Date().toISOString(),
|
|
186
|
+
source: specPath
|
|
187
|
+
};
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return {
|
|
190
|
+
error: error.message,
|
|
191
|
+
path: specPath
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Extract proposal.md metadata
|
|
198
|
+
* @param {string} proposalPath - Path to proposal.md file
|
|
199
|
+
* @returns {Object} Proposal metadata
|
|
200
|
+
*/
|
|
201
|
+
export function extractProposalMetadata(proposalPath) {
|
|
202
|
+
if (!existsSync(proposalPath)) {
|
|
203
|
+
return {
|
|
204
|
+
error: 'Proposal file not found',
|
|
205
|
+
path: proposalPath
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const content = readFileSync(proposalPath, 'utf8');
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
id: extractField(content, 'ID') || extractField(content, 'Feature'),
|
|
214
|
+
status: extractField(content, 'Status'),
|
|
215
|
+
|
|
216
|
+
summary: {
|
|
217
|
+
problem: extractFirstParagraph(content, '## Problem Statement'),
|
|
218
|
+
solution: extractFirstParagraph(content, '## Proposed Solution'),
|
|
219
|
+
tags: autoGenerateTags(content)
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
successMetrics: extractBulletPoints(content, '## Success Metrics').slice(0, 5),
|
|
223
|
+
|
|
224
|
+
generatedAt: new Date().toISOString(),
|
|
225
|
+
source: proposalPath
|
|
226
|
+
};
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return {
|
|
229
|
+
error: error.message,
|
|
230
|
+
path: proposalPath
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Extract decisions.md metadata (ADRs)
|
|
237
|
+
* @param {string} decisionsPath - Path to decisions.md file
|
|
238
|
+
* @returns {Object} Decisions metadata
|
|
239
|
+
*/
|
|
240
|
+
export function extractDecisionsMetadata(decisionsPath) {
|
|
241
|
+
if (!existsSync(decisionsPath)) {
|
|
242
|
+
return {
|
|
243
|
+
error: 'Decisions file not found',
|
|
244
|
+
path: decisionsPath
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const content = readFileSync(decisionsPath, 'utf8');
|
|
250
|
+
const decisions = [];
|
|
251
|
+
|
|
252
|
+
// Match ADR pattern: ### ADR-001: Title
|
|
253
|
+
const adrRegex = /### (ADR-\d+): (.+?)\n([\s\S]*?)(?=### ADR-|\n## |$)/g;
|
|
254
|
+
let match;
|
|
255
|
+
|
|
256
|
+
while ((match = adrRegex.exec(content)) !== null) {
|
|
257
|
+
const [, id, title, body] = match;
|
|
258
|
+
|
|
259
|
+
decisions.push({
|
|
260
|
+
id,
|
|
261
|
+
title: title.trim(),
|
|
262
|
+
summary: body.trim().substring(0, 200), // First 200 chars
|
|
263
|
+
fullPath: `${decisionsPath}#${id.toLowerCase()}`
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
decisions,
|
|
269
|
+
total: decisions.length,
|
|
270
|
+
generatedAt: new Date().toISOString(),
|
|
271
|
+
source: decisionsPath
|
|
272
|
+
};
|
|
273
|
+
} catch (error) {
|
|
274
|
+
return {
|
|
275
|
+
error: error.message,
|
|
276
|
+
path: decisionsPath
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Extract tasks.json metadata (summary only)
|
|
283
|
+
* @param {string} tasksPath - Path to tasks.json file
|
|
284
|
+
* @returns {Object} Tasks metadata
|
|
285
|
+
*/
|
|
286
|
+
export function extractTasksMetadata(tasksPath) {
|
|
287
|
+
if (!existsSync(tasksPath)) {
|
|
288
|
+
return {
|
|
289
|
+
error: 'Tasks file not found',
|
|
290
|
+
path: tasksPath
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const content = readFileSync(tasksPath, 'utf8');
|
|
296
|
+
const tasks = JSON.parse(content);
|
|
297
|
+
|
|
298
|
+
const regularTasks = tasks.tasks?.filter(t => t.id?.startsWith('T')) || [];
|
|
299
|
+
const checkpoints = tasks.tasks?.filter(t => t.id?.startsWith('CHECKPOINT')) || [];
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
total: tasks.tasks?.length || 0,
|
|
303
|
+
regularTasks: regularTasks.length,
|
|
304
|
+
checkpoints: checkpoints.length,
|
|
305
|
+
|
|
306
|
+
categories: tasks.categories || {},
|
|
307
|
+
|
|
308
|
+
byStatus: {
|
|
309
|
+
pending: regularTasks.filter(t => t.status === 'pending').length,
|
|
310
|
+
inProgress: regularTasks.filter(t => t.status === 'in_progress').length,
|
|
311
|
+
completed: regularTasks.filter(t => t.status === 'completed').length,
|
|
312
|
+
blocked: regularTasks.filter(t => t.status === 'blocked').length
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
estimatedTime: regularTasks.reduce((sum, t) => sum + (t.estimatedMinutes || 0), 0),
|
|
316
|
+
|
|
317
|
+
nextTask: regularTasks.find(t => t.status === 'pending' &&
|
|
318
|
+
(!t.dependencies || t.dependencies.length === 0)),
|
|
319
|
+
|
|
320
|
+
generatedAt: new Date().toISOString(),
|
|
321
|
+
source: tasksPath
|
|
322
|
+
};
|
|
323
|
+
} catch (error) {
|
|
324
|
+
return {
|
|
325
|
+
error: error.message,
|
|
326
|
+
path: tasksPath
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Extract comprehensive feature metadata
|
|
333
|
+
* @param {Object} featureState - Feature state from state.json
|
|
334
|
+
* @returns {Object} Complete feature metadata
|
|
335
|
+
*/
|
|
336
|
+
export function extractFeatureMetadata(featureState) {
|
|
337
|
+
const metadata = {
|
|
338
|
+
version: "1.0.0",
|
|
339
|
+
feature: featureState.name || 'unknown',
|
|
340
|
+
status: featureState.status,
|
|
341
|
+
phase: featureState.phase,
|
|
342
|
+
|
|
343
|
+
agents: featureState.activeAgents || [],
|
|
344
|
+
checkpoints: featureState.checkpoints || [],
|
|
345
|
+
|
|
346
|
+
outputs: {},
|
|
347
|
+
quickLinks: {}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// Extract metadata from each output
|
|
351
|
+
if (featureState.outputs?.proposal?.created) {
|
|
352
|
+
metadata.outputs.proposal = extractProposalMetadata(featureState.outputs.proposal.path);
|
|
353
|
+
metadata.quickLinks.proposal = featureState.outputs.proposal.path;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (featureState.outputs?.spec?.created) {
|
|
357
|
+
metadata.outputs.spec = extractSpecMetadata(featureState.outputs.spec.path);
|
|
358
|
+
metadata.quickLinks.spec = featureState.outputs.spec.path;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (featureState.outputs?.decisions?.created) {
|
|
362
|
+
metadata.outputs.decisions = extractDecisionsMetadata(featureState.outputs.decisions.path);
|
|
363
|
+
metadata.quickLinks.decisions = featureState.outputs.decisions.path;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (featureState.outputs?.tasks?.created) {
|
|
367
|
+
metadata.outputs.tasks = extractTasksMetadata(featureState.outputs.tasks.path);
|
|
368
|
+
metadata.quickLinks.tasks = featureState.outputs.tasks.path;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Calculate progress
|
|
372
|
+
if (featureState.tasks) {
|
|
373
|
+
metadata.progress = {
|
|
374
|
+
total: featureState.tasks.total || 0,
|
|
375
|
+
completed: featureState.tasks.completed || 0,
|
|
376
|
+
percentage: featureState.tasks.total > 0
|
|
377
|
+
? Math.round((featureState.tasks.completed / featureState.tasks.total) * 100)
|
|
378
|
+
: 0
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
metadata.generatedAt = new Date().toISOString();
|
|
383
|
+
|
|
384
|
+
return metadata;
|
|
385
|
+
}
|