specweave 0.17.16 → 0.18.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 +405 -2495
- package/README.md +92 -2
- package/dist/plugins/specweave/lib/hooks/sync-living-docs.d.ts.map +1 -1
- package/dist/plugins/specweave/lib/hooks/sync-living-docs.js +188 -36
- package/dist/plugins/specweave/lib/hooks/sync-living-docs.js.map +1 -1
- package/dist/plugins/specweave-ado/lib/ado-status-sync.d.ts +54 -0
- package/dist/plugins/specweave-ado/lib/ado-status-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-ado/lib/ado-status-sync.js +86 -0
- package/dist/plugins/specweave-ado/lib/ado-status-sync.js.map +1 -0
- package/dist/plugins/specweave-github/lib/duplicate-detector.d.ts +139 -0
- package/dist/plugins/specweave-github/lib/duplicate-detector.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/duplicate-detector.js +389 -0
- package/dist/plugins/specweave-github/lib/duplicate-detector.js.map +1 -0
- package/dist/plugins/specweave-github/lib/enhanced-github-sync.d.ts +26 -0
- package/dist/plugins/specweave-github/lib/enhanced-github-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/enhanced-github-sync.js +249 -0
- package/dist/plugins/specweave-github/lib/enhanced-github-sync.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-client.d.ts +1 -1
- package/dist/plugins/specweave-github/lib/github-client.d.ts.map +1 -1
- package/dist/plugins/specweave-github/lib/github-client.js +25 -13
- package/dist/plugins/specweave-github/lib/github-client.js.map +1 -1
- package/dist/plugins/specweave-github/lib/github-epic-sync.d.ts +83 -0
- package/dist/plugins/specweave-github/lib/github-epic-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-epic-sync.js +451 -0
- package/dist/plugins/specweave-github/lib/github-epic-sync.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-status-sync.d.ts +43 -0
- package/dist/plugins/specweave-github/lib/github-status-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-status-sync.js +82 -0
- package/dist/plugins/specweave-github/lib/github-status-sync.js.map +1 -0
- package/dist/plugins/specweave-github/lib/task-sync.d.ts +5 -0
- package/dist/plugins/specweave-github/lib/task-sync.d.ts.map +1 -1
- package/dist/plugins/specweave-github/lib/task-sync.js +38 -2
- package/dist/plugins/specweave-github/lib/task-sync.js.map +1 -1
- package/dist/plugins/specweave-jira/lib/jira-epic-sync.d.ts +66 -0
- package/dist/plugins/specweave-jira/lib/jira-epic-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-jira/lib/jira-epic-sync.js +274 -0
- package/dist/plugins/specweave-jira/lib/jira-epic-sync.js.map +1 -0
- package/dist/plugins/specweave-jira/lib/jira-status-sync.d.ts +56 -0
- package/dist/plugins/specweave-jira/lib/jira-status-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-jira/lib/jira-status-sync.js +93 -0
- package/dist/plugins/specweave-jira/lib/jira-status-sync.js.map +1 -0
- package/dist/src/cli/helpers/issue-tracker/index.d.ts.map +1 -1
- package/dist/src/cli/helpers/issue-tracker/index.js +48 -3
- package/dist/src/cli/helpers/issue-tracker/index.js.map +1 -1
- package/dist/src/core/living-docs/hierarchy-mapper.d.ts +142 -0
- package/dist/src/core/living-docs/hierarchy-mapper.d.ts.map +1 -0
- package/dist/src/core/living-docs/hierarchy-mapper.js +453 -0
- package/dist/src/core/living-docs/hierarchy-mapper.js.map +1 -0
- package/dist/src/core/living-docs/index.d.ts +10 -84
- package/dist/src/core/living-docs/index.d.ts.map +1 -1
- package/dist/src/core/living-docs/index.js +10 -164
- package/dist/src/core/living-docs/index.js.map +1 -1
- package/dist/src/core/living-docs/spec-distributor.d.ts +106 -0
- package/dist/src/core/living-docs/spec-distributor.d.ts.map +1 -0
- package/dist/src/core/living-docs/spec-distributor.js +823 -0
- package/dist/src/core/living-docs/spec-distributor.js.map +1 -0
- package/dist/src/core/living-docs/types.d.ts +201 -0
- package/dist/src/core/living-docs/types.d.ts.map +1 -0
- package/dist/src/core/living-docs/types.js +15 -0
- package/dist/src/core/living-docs/types.js.map +1 -0
- package/dist/src/core/logging/prompt-logger.d.ts +70 -0
- package/dist/src/core/logging/prompt-logger.d.ts.map +1 -0
- package/dist/src/core/logging/prompt-logger.js +247 -0
- package/dist/src/core/logging/prompt-logger.js.map +1 -0
- package/dist/src/core/status-line/status-line-manager.d.ts +15 -24
- package/dist/src/core/status-line/status-line-manager.d.ts.map +1 -1
- package/dist/src/core/status-line/status-line-manager.js +33 -70
- package/dist/src/core/status-line/status-line-manager.js.map +1 -1
- package/dist/src/core/status-line/types.d.ts +19 -31
- package/dist/src/core/status-line/types.d.ts.map +1 -1
- package/dist/src/core/status-line/types.js +5 -9
- package/dist/src/core/status-line/types.js.map +1 -1
- package/dist/src/core/sync/conflict-resolver.d.ts +66 -0
- package/dist/src/core/sync/conflict-resolver.d.ts.map +1 -0
- package/dist/src/core/sync/conflict-resolver.js +108 -0
- package/dist/src/core/sync/conflict-resolver.js.map +1 -0
- package/dist/src/core/sync/enhanced-content-builder.d.ts +77 -0
- package/dist/src/core/sync/enhanced-content-builder.d.ts.map +1 -0
- package/dist/src/core/sync/enhanced-content-builder.js +199 -0
- package/dist/src/core/sync/enhanced-content-builder.js.map +1 -0
- package/dist/src/core/sync/label-detector.d.ts +66 -0
- package/dist/src/core/sync/label-detector.d.ts.map +1 -0
- package/dist/src/core/sync/label-detector.js +211 -0
- package/dist/src/core/sync/label-detector.js.map +1 -0
- package/dist/src/core/sync/retry-logic.d.ts +64 -0
- package/dist/src/core/sync/retry-logic.d.ts.map +1 -0
- package/dist/src/core/sync/retry-logic.js +165 -0
- package/dist/src/core/sync/retry-logic.js.map +1 -0
- package/dist/src/core/sync/spec-increment-mapper.d.ts +100 -0
- package/dist/src/core/sync/spec-increment-mapper.d.ts.map +1 -0
- package/dist/src/core/sync/spec-increment-mapper.js +424 -0
- package/dist/src/core/sync/spec-increment-mapper.js.map +1 -0
- package/dist/src/core/sync/status-cache.d.ts +91 -0
- package/dist/src/core/sync/status-cache.d.ts.map +1 -0
- package/dist/src/core/sync/status-cache.js +140 -0
- package/dist/src/core/sync/status-cache.js.map +1 -0
- package/dist/src/core/sync/status-mapper.d.ts +69 -0
- package/dist/src/core/sync/status-mapper.d.ts.map +1 -0
- package/dist/src/core/sync/status-mapper.js +90 -0
- package/dist/src/core/sync/status-mapper.js.map +1 -0
- package/dist/src/core/sync/status-sync-engine.d.ts +162 -0
- package/dist/src/core/sync/status-sync-engine.d.ts.map +1 -0
- package/dist/src/core/sync/status-sync-engine.js +347 -0
- package/dist/src/core/sync/status-sync-engine.js.map +1 -0
- package/dist/src/core/sync/sync-event-logger.d.ts +99 -0
- package/dist/src/core/sync/sync-event-logger.d.ts.map +1 -0
- package/dist/src/core/sync/sync-event-logger.js +103 -0
- package/dist/src/core/sync/sync-event-logger.js.map +1 -0
- package/dist/src/core/sync/workflow-detector.d.ts +95 -0
- package/dist/src/core/sync/workflow-detector.d.ts.map +1 -0
- package/dist/src/core/sync/workflow-detector.js +175 -0
- package/dist/src/core/sync/workflow-detector.js.map +1 -0
- package/dist/src/core/types/config.d.ts.map +1 -1
- package/dist/src/core/types/config.js +31 -0
- package/dist/src/core/types/config.js.map +1 -1
- package/dist/src/utils/github-url.d.ts +53 -0
- package/dist/src/utils/github-url.d.ts.map +1 -0
- package/dist/src/utils/github-url.js +90 -0
- package/dist/src/utils/github-url.js.map +1 -0
- package/dist/src/utils/spec-parser.d.ts +145 -0
- package/dist/src/utils/spec-parser.d.ts.map +1 -0
- package/dist/src/utils/spec-parser.js +640 -0
- package/dist/src/utils/spec-parser.js.map +1 -0
- package/package.json +1 -1
- package/plugins/specweave/agents/pm/AGENT.md +1 -1
- package/plugins/specweave/agents/pm/templates/increment-spec.md +158 -0
- package/plugins/specweave/agents/pm/templates/living-docs-spec.md +113 -0
- package/plugins/specweave/commands/specweave-done.md +163 -0
- package/plugins/specweave/hooks/lib/update-status-line.sh +79 -111
- package/plugins/specweave/hooks/post-increment-planning.sh +107 -35
- package/plugins/specweave/lib/hooks/sync-living-docs.js +139 -34
- package/plugins/specweave/lib/hooks/sync-living-docs.ts +234 -38
- package/plugins/specweave/skills/SKILLS-INDEX.md +4 -24
- package/plugins/specweave/skills/increment-planner/SKILL.md +94 -0
- package/plugins/specweave/skills/increment-work-router/SKILL.md +466 -0
- package/plugins/specweave-ado/lib/ado-status-sync.js +80 -0
- package/plugins/specweave-ado/lib/ado-status-sync.ts +121 -0
- package/plugins/specweave-github/commands/specweave-github-cleanup-duplicates.md +205 -0
- package/plugins/specweave-github/commands/specweave-github-sync-epic.md +248 -0
- package/plugins/specweave-github/lib/duplicate-detector.js +370 -0
- package/plugins/specweave-github/lib/duplicate-detector.ts +525 -0
- package/plugins/specweave-github/lib/enhanced-github-sync.js +220 -0
- package/plugins/specweave-github/lib/enhanced-github-sync.ts +322 -0
- package/plugins/specweave-github/lib/github-client.js +21 -10
- package/plugins/specweave-github/lib/github-client.ts +27 -16
- package/plugins/specweave-github/lib/github-epic-sync.js +489 -0
- package/plugins/specweave-github/lib/github-epic-sync.ts +690 -0
- package/plugins/specweave-github/lib/github-status-sync.js +71 -0
- package/plugins/specweave-github/lib/github-status-sync.ts +107 -0
- package/plugins/specweave-github/lib/task-sync.js +33 -2
- package/plugins/specweave-github/lib/task-sync.ts +44 -2
- package/plugins/specweave-jira/commands/specweave-jira-sync-epic.md +267 -0
- package/plugins/specweave-jira/lib/enhanced-jira-sync.ts.disabled +222 -0
- package/plugins/specweave-jira/lib/jira-epic-sync.js +304 -0
- package/plugins/specweave-jira/lib/jira-epic-sync.ts +459 -0
- package/plugins/specweave-jira/lib/jira-status-sync.js +79 -0
- package/plugins/specweave-jira/lib/jira-status-sync.ts +139 -0
- package/src/templates/AGENTS.md.template +88 -1
- package/src/templates/CLAUDE.md.template +49 -0
- package/plugins/specweave/skills/increment-quality-judge/SKILL.md +0 -524
- package/plugins/specweave/skills/plugin-installer/SKILL.md +0 -353
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpecWeave Spec Distributor
|
|
3
|
+
*
|
|
4
|
+
* Distributes increment specs into hierarchical living docs structure:
|
|
5
|
+
* - Epic (SPEC-###.md) - High-level summary
|
|
6
|
+
* - User Stories (us-###.md) - Detailed requirements
|
|
7
|
+
* - Tasks (tasks.md) - Implementation details (already exists)
|
|
8
|
+
*
|
|
9
|
+
* @author SpecWeave Team
|
|
10
|
+
* @version 2.0.0
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'fs-extra';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { HierarchyMapper } from './hierarchy-mapper.js';
|
|
15
|
+
import { detectPrimaryGitHubRemote } from '../../utils/git-detector.js';
|
|
16
|
+
/**
|
|
17
|
+
* SpecDistributor - Distributes increment specs into hierarchical living docs
|
|
18
|
+
*/
|
|
19
|
+
export class SpecDistributor {
|
|
20
|
+
constructor(projectRoot, config) {
|
|
21
|
+
this.githubRemote = null;
|
|
22
|
+
this.projectRoot = projectRoot;
|
|
23
|
+
// Detect project ID from config or use default
|
|
24
|
+
const projectId = config?.specsDir?.includes('/specs/')
|
|
25
|
+
? config.specsDir.split('/specs/')[1]?.split('/')[0] || 'default'
|
|
26
|
+
: 'default';
|
|
27
|
+
this.config = {
|
|
28
|
+
specsDir: path.join(projectRoot, '.specweave', 'docs', 'internal', 'specs', projectId),
|
|
29
|
+
userStoriesSubdir: 'user-stories',
|
|
30
|
+
epicFilePattern: 'SPEC-{id}-{name}.md',
|
|
31
|
+
userStoryFilePattern: 'us-{id}-{name}.md',
|
|
32
|
+
generateFrontmatter: true,
|
|
33
|
+
generateCrossLinks: true,
|
|
34
|
+
preserveOriginal: true,
|
|
35
|
+
overwriteExisting: false,
|
|
36
|
+
createBackups: true,
|
|
37
|
+
...config,
|
|
38
|
+
};
|
|
39
|
+
// Initialize HierarchyMapper with same project config
|
|
40
|
+
this.hierarchyMapper = new HierarchyMapper(projectRoot, {
|
|
41
|
+
projectId: config?.specsDir?.includes('/specs/')
|
|
42
|
+
? config.specsDir.split('/specs/')[1]?.split('/')[0] || 'default'
|
|
43
|
+
: 'default'
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Distribute increment spec into epic + user story files
|
|
48
|
+
*/
|
|
49
|
+
async distribute(incrementId) {
|
|
50
|
+
const errors = [];
|
|
51
|
+
const warnings = [];
|
|
52
|
+
try {
|
|
53
|
+
// Detect GitHub remote for generating GitHub URLs (if not already detected)
|
|
54
|
+
if (!this.githubRemote) {
|
|
55
|
+
this.githubRemote = await detectPrimaryGitHubRemote(this.projectRoot);
|
|
56
|
+
}
|
|
57
|
+
// Step 0a: Parse increment spec to detect project ID
|
|
58
|
+
const parsed = await this.parseIncrementSpec(incrementId);
|
|
59
|
+
// Step 0b: Update config if project ID is specified in frontmatter
|
|
60
|
+
let projectId = this.config.specsDir.split('/specs/')[1]?.split('/')[0] || 'default';
|
|
61
|
+
if (parsed.project) {
|
|
62
|
+
projectId = parsed.project;
|
|
63
|
+
// Update config paths for correct project
|
|
64
|
+
this.config.specsDir = path.join(this.projectRoot, '.specweave', 'docs', 'internal', 'specs', projectId);
|
|
65
|
+
// Recreate hierarchy mapper with correct project ID
|
|
66
|
+
this.hierarchyMapper = new HierarchyMapper(this.projectRoot, {
|
|
67
|
+
projectId,
|
|
68
|
+
specsBaseDir: this.config.specsDir,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// Step 0c: Detect feature folder using HierarchyMapper (NEW: feature-based naming)
|
|
72
|
+
console.log(` 🔍 Detecting feature folder for ${incrementId}...`);
|
|
73
|
+
const epicMapping = await this.hierarchyMapper.detectFeatureMapping(incrementId);
|
|
74
|
+
console.log(` 📁 Mapped to ${epicMapping.featureFolder} (confidence: ${epicMapping.confidence}%, method: ${epicMapping.detectionMethod})`);
|
|
75
|
+
// Ensure specs base directory exists (for multi-project support)
|
|
76
|
+
await fs.ensureDir(this.config.specsDir);
|
|
77
|
+
// Step 2: Classify content (pass epicMapping to use feature folder as ID)
|
|
78
|
+
const classified = await this.classifyContent(parsed, epicMapping);
|
|
79
|
+
// Step 3: Generate epic file
|
|
80
|
+
const epic = await this.generateEpicFile(classified, incrementId);
|
|
81
|
+
// Step 4: Generate user story files
|
|
82
|
+
const userStories = await this.generateUserStoryFiles(classified, incrementId);
|
|
83
|
+
// Step 5: Write files (using epicMapping paths)
|
|
84
|
+
const epicPath = await this.writeEpicFile(epic, epicMapping);
|
|
85
|
+
const userStoryPaths = await this.writeUserStoryFiles(userStories, epicMapping);
|
|
86
|
+
// Step 6: Update tasks.md with bidirectional links to user stories (CRITICAL!)
|
|
87
|
+
await this.updateTasksWithUserStoryLinks(incrementId, userStories, epicMapping);
|
|
88
|
+
return {
|
|
89
|
+
epic,
|
|
90
|
+
userStories,
|
|
91
|
+
incrementId,
|
|
92
|
+
specId: epic.id,
|
|
93
|
+
totalStories: userStories.length,
|
|
94
|
+
totalFiles: 1 + userStories.length,
|
|
95
|
+
epicPath,
|
|
96
|
+
userStoryPaths,
|
|
97
|
+
success: true,
|
|
98
|
+
errors,
|
|
99
|
+
warnings,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
errors.push(`Distribution failed: ${error}`);
|
|
104
|
+
throw new Error(`Failed to distribute increment ${incrementId}: ${error}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Parse increment spec into structured data
|
|
109
|
+
*/
|
|
110
|
+
async parseIncrementSpec(incrementId) {
|
|
111
|
+
const specPath = path.join(this.projectRoot, '.specweave', 'increments', incrementId, 'spec.md');
|
|
112
|
+
if (!fs.existsSync(specPath)) {
|
|
113
|
+
throw new Error(`Increment spec not found: ${specPath}`);
|
|
114
|
+
}
|
|
115
|
+
const content = await fs.readFile(specPath, 'utf-8');
|
|
116
|
+
// Load external links from metadata.json (NEW: source of truth for external integrations)
|
|
117
|
+
const externalLinks = await this.loadExternalLinks(incrementId);
|
|
118
|
+
// Extract YAML frontmatter if present
|
|
119
|
+
let frontmatter = {};
|
|
120
|
+
let bodyContent = content;
|
|
121
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
122
|
+
if (frontmatterMatch) {
|
|
123
|
+
try {
|
|
124
|
+
const yaml = await import('yaml');
|
|
125
|
+
frontmatter = yaml.parse(frontmatterMatch[1]);
|
|
126
|
+
bodyContent = content.slice(frontmatterMatch[0].length).trim();
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
console.warn(` ⚠️ Failed to parse frontmatter for ${incrementId}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Extract title (try multiple patterns)
|
|
133
|
+
let title = frontmatter.title || '';
|
|
134
|
+
if (!title) {
|
|
135
|
+
// Pattern 1: # SPEC-####: Title
|
|
136
|
+
const specTitleMatch = bodyContent.match(/^#\s+SPEC-\d+:\s+(.+)$/m);
|
|
137
|
+
if (specTitleMatch)
|
|
138
|
+
title = specTitleMatch[1].trim();
|
|
139
|
+
}
|
|
140
|
+
if (!title) {
|
|
141
|
+
// Pattern 2: # Increment ####: Title
|
|
142
|
+
const incTitleMatch = bodyContent.match(/^#\s+Increment\s+\d+:\s+(.+)$/m);
|
|
143
|
+
if (incTitleMatch)
|
|
144
|
+
title = incTitleMatch[1].trim();
|
|
145
|
+
}
|
|
146
|
+
if (!title) {
|
|
147
|
+
// Pattern 3: First # heading
|
|
148
|
+
const headingMatch = bodyContent.match(/^#\s+(.+)$/m);
|
|
149
|
+
if (headingMatch) {
|
|
150
|
+
title = headingMatch[1]
|
|
151
|
+
.replace(/^SPEC-\d+:\s*/, '')
|
|
152
|
+
.replace(/^Increment\s+\d+:\s*/, '')
|
|
153
|
+
.trim();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!title) {
|
|
157
|
+
// Fallback: Use increment ID
|
|
158
|
+
title = incrementId
|
|
159
|
+
.replace(/^\d+-/, '')
|
|
160
|
+
.split('-')
|
|
161
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
162
|
+
.join(' ');
|
|
163
|
+
}
|
|
164
|
+
// Extract overview (try multiple sections)
|
|
165
|
+
let overview = '';
|
|
166
|
+
// Try "Quick Overview" or "Executive Summary"
|
|
167
|
+
let overviewMatch = bodyContent.match(/##\s+(?:Quick\s+)?(?:Overview|Executive\s+Summary)\s*\n+([\s\S]*?)(?=\n##|\n---|\Z)/i);
|
|
168
|
+
if (overviewMatch)
|
|
169
|
+
overview = overviewMatch[1].trim();
|
|
170
|
+
if (!overview) {
|
|
171
|
+
// Try "Overview" section
|
|
172
|
+
overviewMatch = bodyContent.match(/##\s+Overview\s*\n+([\s\S]*?)(?=\n##|\n---|\Z)/i);
|
|
173
|
+
if (overviewMatch)
|
|
174
|
+
overview = overviewMatch[1].trim();
|
|
175
|
+
}
|
|
176
|
+
if (!overview) {
|
|
177
|
+
// Try "Problem Statement" section
|
|
178
|
+
const problemMatch = bodyContent.match(/##\s+Problem\s+Statement\s*\n+([\s\S]*?)(?=\n##|\n---|\Z)/i);
|
|
179
|
+
if (problemMatch) {
|
|
180
|
+
// Take first paragraph only
|
|
181
|
+
const firstPara = problemMatch[1].trim().split('\n\n')[0];
|
|
182
|
+
overview = firstPara;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (!overview) {
|
|
186
|
+
// Fallback: First paragraph after title
|
|
187
|
+
const firstParaMatch = bodyContent.match(/^#[^\n]+\n+([^\n]+)/);
|
|
188
|
+
if (firstParaMatch)
|
|
189
|
+
overview = firstParaMatch[1].trim();
|
|
190
|
+
}
|
|
191
|
+
// Extract business value
|
|
192
|
+
const businessValue = [];
|
|
193
|
+
const businessValueMatch = content.match(/\*\*Business Value\*\*:\s*\n([\s\S]*?)(?=\n---|\n##|\Z)/i);
|
|
194
|
+
if (businessValueMatch) {
|
|
195
|
+
const lines = businessValueMatch[1].split('\n');
|
|
196
|
+
for (const line of lines) {
|
|
197
|
+
const bulletMatch = line.match(/^[-*]\s+\*\*(.+?)\*\*:\s+(.+)$/);
|
|
198
|
+
if (bulletMatch) {
|
|
199
|
+
businessValue.push(`${bulletMatch[1]}: ${bulletMatch[2]}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Extract user stories
|
|
204
|
+
const userStories = await this.extractUserStories(content, incrementId);
|
|
205
|
+
return {
|
|
206
|
+
incrementId,
|
|
207
|
+
title,
|
|
208
|
+
overview,
|
|
209
|
+
businessValue,
|
|
210
|
+
project: frontmatter.project, // Project ID from frontmatter (if present)
|
|
211
|
+
userStories,
|
|
212
|
+
externalLinks, // NEW: External links from metadata.json
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Load external links from metadata.json (source of truth)
|
|
217
|
+
*/
|
|
218
|
+
async loadExternalLinks(incrementId) {
|
|
219
|
+
const metadataPath = path.join(this.projectRoot, '.specweave', 'increments', incrementId, 'metadata.json');
|
|
220
|
+
const links = {};
|
|
221
|
+
if (!fs.existsSync(metadataPath)) {
|
|
222
|
+
return links;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf-8'));
|
|
226
|
+
// Extract GitHub link
|
|
227
|
+
if (metadata.github?.url) {
|
|
228
|
+
links.github = metadata.github.url;
|
|
229
|
+
}
|
|
230
|
+
// Extract JIRA link
|
|
231
|
+
if (metadata.jira?.epicKey) {
|
|
232
|
+
links.jira = metadata.jira.epicKey; // Store just the key, can be converted to URL in template
|
|
233
|
+
}
|
|
234
|
+
// Extract Azure DevOps link
|
|
235
|
+
if (metadata.ado?.workItemUrl) {
|
|
236
|
+
links.ado = metadata.ado.workItemUrl;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
console.warn(` ⚠️ Failed to parse metadata.json for ${incrementId}: ${error}`);
|
|
241
|
+
}
|
|
242
|
+
return links;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Extract user stories from increment spec
|
|
246
|
+
*/
|
|
247
|
+
async extractUserStories(content, incrementId) {
|
|
248
|
+
const userStories = [];
|
|
249
|
+
// Find all user story sections (supports both ### and #### patterns, with or without blank line)
|
|
250
|
+
const userStoryPattern = /^###+\s+(US-\d+):\s+(.+?)\s*\n([\s\S]*?)(?=^###+\s+US-|\n---\n|$)/gm;
|
|
251
|
+
let match;
|
|
252
|
+
while ((match = userStoryPattern.exec(content)) !== null) {
|
|
253
|
+
const id = match[1]; // US-001
|
|
254
|
+
const title = match[2];
|
|
255
|
+
const storyContent = match[3];
|
|
256
|
+
// Extract description (As a... I want... So that...) - supports both inline and separate line formats
|
|
257
|
+
const descMatch = storyContent.match(/\*\*As a\*\*\s+(.*?)\s*\n\*\*I want\*\*\s+(.*?)\s*\n\*\*So that\*\*\s+(.*?)(?=\n\n|\*\*Acceptance)/is);
|
|
258
|
+
const description = descMatch
|
|
259
|
+
? `**As a** ${descMatch[1].trim()}\n**I want** ${descMatch[2].trim()}\n**So that** ${descMatch[3].trim()}`
|
|
260
|
+
: '';
|
|
261
|
+
// Extract acceptance criteria
|
|
262
|
+
const acceptanceCriteria = this.extractAcceptanceCriteria(storyContent);
|
|
263
|
+
// Extract business rationale
|
|
264
|
+
const rationaleMatch = storyContent.match(/\*\*Business Rationale\*\*:\s+(.*?)(?=\n\n---|\n\n##|$)/is);
|
|
265
|
+
const businessRationale = rationaleMatch ? rationaleMatch[1].trim() : undefined;
|
|
266
|
+
// Extract phase
|
|
267
|
+
const phaseMatch = content.substring(0, match.index).match(/###\s+(Phase\s+\d+:.*?)$/im);
|
|
268
|
+
const phase = phaseMatch ? phaseMatch[1] : undefined;
|
|
269
|
+
// Determine status (assume complete if in completed increment)
|
|
270
|
+
const status = 'complete'; // Can be enhanced later
|
|
271
|
+
userStories.push({
|
|
272
|
+
id,
|
|
273
|
+
title,
|
|
274
|
+
description,
|
|
275
|
+
acceptanceCriteria,
|
|
276
|
+
tasks: [], // Will be populated later
|
|
277
|
+
businessRationale,
|
|
278
|
+
status,
|
|
279
|
+
phase,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return userStories;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Extract acceptance criteria from user story content
|
|
286
|
+
*/
|
|
287
|
+
extractAcceptanceCriteria(content) {
|
|
288
|
+
const criteria = [];
|
|
289
|
+
// Pattern: - [x] **AC-US1-01**: Description (P1, testable)
|
|
290
|
+
const acPattern = /^[-*]\s+\[([ x])\]\s+\*\*(.+?)\*\*:\s+(.+?)(?:\s+\(([^)]+)\))?$/gm;
|
|
291
|
+
let match;
|
|
292
|
+
while ((match = acPattern.exec(content)) !== null) {
|
|
293
|
+
const completed = match[1] === 'x';
|
|
294
|
+
const id = match[2]; // AC-US1-01
|
|
295
|
+
const description = match[3];
|
|
296
|
+
const metaString = match[4] || ''; // "P1, testable"
|
|
297
|
+
const priority = metaString.match(/P\d/)?.[0];
|
|
298
|
+
const testable = metaString.includes('testable');
|
|
299
|
+
criteria.push({
|
|
300
|
+
id,
|
|
301
|
+
description,
|
|
302
|
+
priority,
|
|
303
|
+
testable,
|
|
304
|
+
completed,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return criteria;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Classify content into epic vs user-story level
|
|
311
|
+
*
|
|
312
|
+
* NEW (v0.18.0): Uses feature folder name as ID (e.g., FS-25-11-14-release-management)
|
|
313
|
+
*/
|
|
314
|
+
async classifyContent(parsed, epicMapping) {
|
|
315
|
+
// Use feature folder name as ID (e.g., FS-25-11-14-release-management)
|
|
316
|
+
// This ensures ID matches folder name
|
|
317
|
+
const specId = epicMapping.featureFolder;
|
|
318
|
+
return {
|
|
319
|
+
epic: {
|
|
320
|
+
id: specId,
|
|
321
|
+
title: parsed.title,
|
|
322
|
+
overview: parsed.overview,
|
|
323
|
+
businessValue: parsed.businessValue,
|
|
324
|
+
status: 'complete',
|
|
325
|
+
},
|
|
326
|
+
userStories: parsed.userStories,
|
|
327
|
+
implementationHistory: [
|
|
328
|
+
{
|
|
329
|
+
increment: parsed.incrementId,
|
|
330
|
+
stories: parsed.userStories.map((us) => us.id),
|
|
331
|
+
status: 'complete',
|
|
332
|
+
date: new Date().toISOString().split('T')[0],
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
externalLinks: parsed.externalLinks || {},
|
|
336
|
+
relatedDocs: [],
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Generate epic file
|
|
341
|
+
*/
|
|
342
|
+
async generateEpicFile(classified, incrementId) {
|
|
343
|
+
// Generate user story summaries
|
|
344
|
+
const userStorySummaries = classified.userStories.map((us) => ({
|
|
345
|
+
id: us.id,
|
|
346
|
+
title: us.title,
|
|
347
|
+
status: us.status,
|
|
348
|
+
phase: us.phase,
|
|
349
|
+
filePath: this.generateUserStoryFilename(us.id, us.title), // User stories are directly in FS folder
|
|
350
|
+
}));
|
|
351
|
+
const completedStories = classified.userStories.filter((us) => us.status === 'complete').length;
|
|
352
|
+
return {
|
|
353
|
+
id: classified.epic.id,
|
|
354
|
+
title: classified.epic.title,
|
|
355
|
+
type: 'epic',
|
|
356
|
+
status: 'complete',
|
|
357
|
+
priority: classified.epic.priority,
|
|
358
|
+
created: new Date().toISOString().split('T')[0],
|
|
359
|
+
lastUpdated: new Date().toISOString().split('T')[0],
|
|
360
|
+
overview: classified.epic.overview,
|
|
361
|
+
businessValue: classified.epic.businessValue,
|
|
362
|
+
implementationHistory: classified.implementationHistory,
|
|
363
|
+
userStories: userStorySummaries,
|
|
364
|
+
externalLinks: classified.externalLinks,
|
|
365
|
+
relatedDocs: classified.relatedDocs,
|
|
366
|
+
totalStories: classified.userStories.length,
|
|
367
|
+
completedStories,
|
|
368
|
+
overallProgress: Math.round((completedStories / classified.userStories.length) * 100),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Generate user story files
|
|
373
|
+
*/
|
|
374
|
+
async generateUserStoryFiles(classified, incrementId) {
|
|
375
|
+
const userStoryFiles = [];
|
|
376
|
+
// Load tasks from tasks.md to extract task references
|
|
377
|
+
const taskMap = await this.loadTaskReferences(incrementId);
|
|
378
|
+
for (const userStory of classified.userStories) {
|
|
379
|
+
// Find tasks that implement this user story
|
|
380
|
+
const tasks = this.findTasksForUserStory(userStory.id, taskMap);
|
|
381
|
+
// Find related user stories (same phase)
|
|
382
|
+
const relatedStories = classified.userStories
|
|
383
|
+
.filter((us) => us.id !== userStory.id && us.phase === userStory.phase)
|
|
384
|
+
.map((us) => ({
|
|
385
|
+
id: us.id,
|
|
386
|
+
title: us.title,
|
|
387
|
+
status: us.status,
|
|
388
|
+
phase: us.phase,
|
|
389
|
+
filePath: this.generateUserStoryFilename(us.id, us.title),
|
|
390
|
+
}));
|
|
391
|
+
userStoryFiles.push({
|
|
392
|
+
id: userStory.id,
|
|
393
|
+
epic: classified.epic.id,
|
|
394
|
+
title: userStory.title,
|
|
395
|
+
status: userStory.status,
|
|
396
|
+
priority: userStory.priority,
|
|
397
|
+
created: new Date().toISOString().split('T')[0],
|
|
398
|
+
completed: userStory.status === 'complete' ? new Date().toISOString().split('T')[0] : undefined,
|
|
399
|
+
description: userStory.description,
|
|
400
|
+
acceptanceCriteria: userStory.acceptanceCriteria,
|
|
401
|
+
implementation: {
|
|
402
|
+
increment: incrementId,
|
|
403
|
+
tasks,
|
|
404
|
+
},
|
|
405
|
+
businessRationale: userStory.businessRationale,
|
|
406
|
+
relatedStories,
|
|
407
|
+
phase: userStory.phase,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
return userStoryFiles;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Load task references from tasks.md (with AC-ID extraction)
|
|
414
|
+
*/
|
|
415
|
+
async loadTaskReferences(incrementId) {
|
|
416
|
+
const tasksPath = path.join(this.projectRoot, '.specweave', 'increments', incrementId, 'tasks.md');
|
|
417
|
+
const taskMap = new Map();
|
|
418
|
+
if (!fs.existsSync(tasksPath)) {
|
|
419
|
+
return taskMap;
|
|
420
|
+
}
|
|
421
|
+
const content = await fs.readFile(tasksPath, 'utf-8');
|
|
422
|
+
// Pattern: ### T-001: Task Title followed by **AC**: field
|
|
423
|
+
// Supports both ## and ### headings
|
|
424
|
+
const taskPattern = /^##+ (T-\d+):\s+(.+?)$[\s\S]*?\*\*AC\*\*:\s*([^\n]+)?/gm;
|
|
425
|
+
let match;
|
|
426
|
+
while ((match = taskPattern.exec(content)) !== null) {
|
|
427
|
+
const taskId = match[1]; // T-001
|
|
428
|
+
const taskTitle = match[2];
|
|
429
|
+
const acList = match[3] || ''; // AC-US1-01, AC-US1-02
|
|
430
|
+
const anchor = this.generateTaskAnchor(taskId, taskTitle);
|
|
431
|
+
// Extract AC-IDs from the list
|
|
432
|
+
const acIds = [];
|
|
433
|
+
const acPattern = /AC-US\d+-\d+/g;
|
|
434
|
+
let acMatch;
|
|
435
|
+
while ((acMatch = acPattern.exec(acList)) !== null) {
|
|
436
|
+
acIds.push(acMatch[0]); // AC-US1-01
|
|
437
|
+
}
|
|
438
|
+
taskMap.set(taskId, {
|
|
439
|
+
id: taskId,
|
|
440
|
+
title: taskTitle,
|
|
441
|
+
anchor,
|
|
442
|
+
path: `../../../../../increments/${incrementId}/tasks.md${anchor}`,
|
|
443
|
+
acIds,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
return taskMap;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Find tasks that implement a user story (using AC-ID based filtering)
|
|
450
|
+
*/
|
|
451
|
+
findTasksForUserStory(userStoryId, taskMap) {
|
|
452
|
+
const tasks = [];
|
|
453
|
+
// Extract US number from userStoryId (US-001 → "1")
|
|
454
|
+
const usMatch = userStoryId.match(/US-(\d+)/);
|
|
455
|
+
if (!usMatch) {
|
|
456
|
+
return tasks;
|
|
457
|
+
}
|
|
458
|
+
const usNumber = parseInt(usMatch[1], 10); // 1
|
|
459
|
+
// Find tasks that reference this user story's AC-IDs
|
|
460
|
+
for (const task of taskMap.values()) {
|
|
461
|
+
// Check if task has AC-IDs for this user story (AC-US1-01, AC-US1-02, etc.)
|
|
462
|
+
const hasMatchingAC = task.acIds.some((acId) => {
|
|
463
|
+
const acMatch = acId.match(/AC-US(\d+)-\d+/);
|
|
464
|
+
return acMatch && parseInt(acMatch[1], 10) === usNumber;
|
|
465
|
+
});
|
|
466
|
+
if (hasMatchingAC) {
|
|
467
|
+
tasks.push(task);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return tasks;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Generate task anchor
|
|
474
|
+
*/
|
|
475
|
+
generateTaskAnchor(taskId, taskTitle) {
|
|
476
|
+
const slug = taskTitle
|
|
477
|
+
.toLowerCase()
|
|
478
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
479
|
+
.replace(/^-|-$/g, '');
|
|
480
|
+
return `#${taskId.toLowerCase()}-${slug}`;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Generate user story filename
|
|
484
|
+
*/
|
|
485
|
+
generateUserStoryFilename(id, title) {
|
|
486
|
+
const slug = title
|
|
487
|
+
.toLowerCase()
|
|
488
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
489
|
+
.replace(/^-|-$/g, '');
|
|
490
|
+
return `${id.toLowerCase()}-${slug}.md`;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Write feature file to disk (NEW: writes to FEATURE.md instead of README.md)
|
|
494
|
+
*/
|
|
495
|
+
async writeEpicFile(epic, epicMapping) {
|
|
496
|
+
// Write to feature-folder/FEATURE.md (feature overview - high-level summary)
|
|
497
|
+
const featurePath = path.join(epicMapping.featurePath, 'FEATURE.md');
|
|
498
|
+
const content = this.formatEpicFile(epic);
|
|
499
|
+
await fs.ensureDir(path.dirname(featurePath));
|
|
500
|
+
await fs.writeFile(featurePath, content, 'utf-8');
|
|
501
|
+
console.log(` ✅ Written feature overview to ${epicMapping.featureFolder}/FEATURE.md`);
|
|
502
|
+
return featurePath;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Write user story files to disk (NEW: writes directly to feature folder)
|
|
506
|
+
*/
|
|
507
|
+
async writeUserStoryFiles(userStories, epicMapping) {
|
|
508
|
+
// Write user stories directly to feature folder (not in subfolder)
|
|
509
|
+
const featureDir = epicMapping.featurePath;
|
|
510
|
+
await fs.ensureDir(featureDir);
|
|
511
|
+
const paths = [];
|
|
512
|
+
for (const userStory of userStories) {
|
|
513
|
+
const filename = this.generateUserStoryFilename(userStory.id, userStory.title);
|
|
514
|
+
const filePath = path.join(featureDir, filename);
|
|
515
|
+
const content = this.formatUserStoryFile(userStory);
|
|
516
|
+
await fs.writeFile(filePath, content, 'utf-8');
|
|
517
|
+
paths.push(filePath);
|
|
518
|
+
}
|
|
519
|
+
console.log(` ✅ Written ${userStories.length} user stories directly to ${epicMapping.featureFolder}/`);
|
|
520
|
+
return paths;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Format epic file as markdown
|
|
524
|
+
*/
|
|
525
|
+
formatEpicFile(epic) {
|
|
526
|
+
const lines = [];
|
|
527
|
+
// Frontmatter
|
|
528
|
+
lines.push('---');
|
|
529
|
+
lines.push(`id: ${epic.id}`);
|
|
530
|
+
lines.push(`title: "${epic.title}"`);
|
|
531
|
+
lines.push(`type: epic`);
|
|
532
|
+
lines.push(`status: ${epic.status}`);
|
|
533
|
+
if (epic.priority)
|
|
534
|
+
lines.push(`priority: ${epic.priority}`);
|
|
535
|
+
lines.push(`created: ${epic.created}`);
|
|
536
|
+
lines.push(`last_updated: ${epic.lastUpdated}`);
|
|
537
|
+
lines.push('---');
|
|
538
|
+
lines.push('');
|
|
539
|
+
// Title
|
|
540
|
+
lines.push(`# ${epic.id}: ${epic.title}`);
|
|
541
|
+
lines.push('');
|
|
542
|
+
lines.push(epic.overview);
|
|
543
|
+
lines.push('');
|
|
544
|
+
// Business Value
|
|
545
|
+
if (epic.businessValue.length > 0) {
|
|
546
|
+
lines.push('**Business Value**:');
|
|
547
|
+
lines.push('');
|
|
548
|
+
for (const value of epic.businessValue) {
|
|
549
|
+
lines.push(`- **${value.split(':')[0]}**: ${value.split(':').slice(1).join(':').trim()}`);
|
|
550
|
+
}
|
|
551
|
+
lines.push('');
|
|
552
|
+
}
|
|
553
|
+
lines.push('---');
|
|
554
|
+
lines.push('');
|
|
555
|
+
// Implementation History
|
|
556
|
+
if (epic.implementationHistory.length > 0) {
|
|
557
|
+
lines.push('## Implementation History');
|
|
558
|
+
lines.push('');
|
|
559
|
+
lines.push('| Increment | User Stories | Status | Completion Date |');
|
|
560
|
+
lines.push('|-----------|--------------|--------|----------------|');
|
|
561
|
+
for (const entry of epic.implementationHistory) {
|
|
562
|
+
const statusEmoji = entry.status === 'complete' ? '✅' : entry.status === 'in-progress' ? '⏳' : '📋';
|
|
563
|
+
// Generate increment link (prefer GitHub URL for deployed version, fallback to relative path)
|
|
564
|
+
let incrementLink;
|
|
565
|
+
if (this.githubRemote && this.githubRemote.owner && this.githubRemote.repo) {
|
|
566
|
+
// GitHub URL (works on deployed version)
|
|
567
|
+
incrementLink = `[${entry.increment}](https://github.com/${this.githubRemote.owner}/${this.githubRemote.repo}/tree/develop/.specweave/increments/${entry.increment})`;
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
// Fallback to relative path (5 levels up, not 4!)
|
|
571
|
+
incrementLink = `[${entry.increment}](../../../../../increments/${entry.increment}/tasks.md)`;
|
|
572
|
+
}
|
|
573
|
+
// Handle empty stories array (no user stories in spec)
|
|
574
|
+
let storiesText;
|
|
575
|
+
if (entry.stories.length === 0) {
|
|
576
|
+
storiesText = 'Implementation only (no user stories)';
|
|
577
|
+
}
|
|
578
|
+
else if (entry.stories.length === 1) {
|
|
579
|
+
storiesText = entry.stories[0];
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
const firstStory = entry.stories[0];
|
|
583
|
+
const lastStory = entry.stories[entry.stories.length - 1];
|
|
584
|
+
storiesText = `${firstStory} through ${lastStory} (all)`;
|
|
585
|
+
}
|
|
586
|
+
lines.push(`| ${incrementLink} | ${storiesText} | ${statusEmoji} ${entry.status.charAt(0).toUpperCase() + entry.status.slice(1)} | ${entry.date || '-'} |`);
|
|
587
|
+
}
|
|
588
|
+
lines.push('');
|
|
589
|
+
// Handle division by zero (no user stories)
|
|
590
|
+
const progressText = epic.totalStories === 0
|
|
591
|
+
? 'No user stories (implementation only)'
|
|
592
|
+
: `${epic.completedStories}/${epic.totalStories} user stories complete (${epic.overallProgress}%)`;
|
|
593
|
+
lines.push(`**Overall Progress**: ${progressText}`);
|
|
594
|
+
lines.push('');
|
|
595
|
+
lines.push('---');
|
|
596
|
+
lines.push('');
|
|
597
|
+
}
|
|
598
|
+
// User Stories
|
|
599
|
+
lines.push('## User Stories');
|
|
600
|
+
lines.push('');
|
|
601
|
+
// Group by phase
|
|
602
|
+
const phases = new Map();
|
|
603
|
+
for (const story of epic.userStories) {
|
|
604
|
+
const phase = story.phase || 'General';
|
|
605
|
+
if (!phases.has(phase)) {
|
|
606
|
+
phases.set(phase, []);
|
|
607
|
+
}
|
|
608
|
+
phases.get(phase).push(story);
|
|
609
|
+
}
|
|
610
|
+
for (const [phase, stories] of phases) {
|
|
611
|
+
if (phase !== 'General') {
|
|
612
|
+
lines.push(`### ${phase}`);
|
|
613
|
+
lines.push('');
|
|
614
|
+
}
|
|
615
|
+
for (const story of stories) {
|
|
616
|
+
const statusEmoji = story.status === 'complete' ? '✅' : story.status === 'in-progress' ? '⏳' : '📋';
|
|
617
|
+
lines.push(`- [${story.id}: ${story.title}](${story.filePath}) - ${statusEmoji} ${story.status.charAt(0).toUpperCase() + story.status.slice(1)}`);
|
|
618
|
+
}
|
|
619
|
+
lines.push('');
|
|
620
|
+
}
|
|
621
|
+
lines.push('---');
|
|
622
|
+
lines.push('');
|
|
623
|
+
// External Tool Integration (only if there are actual links)
|
|
624
|
+
const hasExternalLinks = epic.externalLinks.github || epic.externalLinks.jira || epic.externalLinks.ado;
|
|
625
|
+
if (hasExternalLinks) {
|
|
626
|
+
lines.push('## External Tool Integration');
|
|
627
|
+
lines.push('');
|
|
628
|
+
// Only show tools that have actual links
|
|
629
|
+
if (epic.externalLinks.github) {
|
|
630
|
+
lines.push(`**GitHub Project**: [${epic.externalLinks.github}](${epic.externalLinks.github})`);
|
|
631
|
+
}
|
|
632
|
+
if (epic.externalLinks.jira) {
|
|
633
|
+
// Convert JIRA key to URL (if it's just a key like SCRUM-23)
|
|
634
|
+
const jiraUrl = epic.externalLinks.jira.startsWith('http')
|
|
635
|
+
? epic.externalLinks.jira
|
|
636
|
+
: `https://jira.atlassian.com/browse/${epic.externalLinks.jira}`;
|
|
637
|
+
lines.push(`**JIRA Epic**: [${epic.externalLinks.jira}](${jiraUrl})`);
|
|
638
|
+
}
|
|
639
|
+
if (epic.externalLinks.ado) {
|
|
640
|
+
lines.push(`**Azure DevOps**: [${epic.externalLinks.ado}](${epic.externalLinks.ado})`);
|
|
641
|
+
}
|
|
642
|
+
lines.push('');
|
|
643
|
+
}
|
|
644
|
+
return lines.join('\n');
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Format user story file as markdown
|
|
648
|
+
*/
|
|
649
|
+
formatUserStoryFile(userStory) {
|
|
650
|
+
const lines = [];
|
|
651
|
+
// Frontmatter
|
|
652
|
+
lines.push('---');
|
|
653
|
+
lines.push(`id: ${userStory.id}`);
|
|
654
|
+
lines.push(`epic: ${userStory.epic}`);
|
|
655
|
+
lines.push(`title: "${userStory.title}"`);
|
|
656
|
+
lines.push(`status: ${userStory.status}`);
|
|
657
|
+
if (userStory.priority)
|
|
658
|
+
lines.push(`priority: ${userStory.priority}`);
|
|
659
|
+
lines.push(`created: ${userStory.created}`);
|
|
660
|
+
if (userStory.completed)
|
|
661
|
+
lines.push(`completed: ${userStory.completed}`);
|
|
662
|
+
lines.push('---');
|
|
663
|
+
lines.push('');
|
|
664
|
+
// Title
|
|
665
|
+
lines.push(`# ${userStory.id}: ${userStory.title}`);
|
|
666
|
+
lines.push('');
|
|
667
|
+
// Feature link (FEATURE.md in same folder)
|
|
668
|
+
lines.push(`**Feature**: [${userStory.epic}](./FEATURE.md)`);
|
|
669
|
+
lines.push('');
|
|
670
|
+
// Description
|
|
671
|
+
lines.push(userStory.description);
|
|
672
|
+
lines.push('');
|
|
673
|
+
lines.push('---');
|
|
674
|
+
lines.push('');
|
|
675
|
+
// Acceptance Criteria
|
|
676
|
+
lines.push('## Acceptance Criteria');
|
|
677
|
+
lines.push('');
|
|
678
|
+
for (const ac of userStory.acceptanceCriteria) {
|
|
679
|
+
const checkbox = ac.completed ? '[x]' : '[ ]';
|
|
680
|
+
const priorityText = ac.priority ? ` (${ac.priority}, testable)` : '';
|
|
681
|
+
lines.push(`- ${checkbox} **${ac.id}**: ${ac.description}${priorityText}`);
|
|
682
|
+
}
|
|
683
|
+
lines.push('');
|
|
684
|
+
lines.push('---');
|
|
685
|
+
lines.push('');
|
|
686
|
+
// Implementation
|
|
687
|
+
lines.push('## Implementation');
|
|
688
|
+
lines.push('');
|
|
689
|
+
lines.push(`**Increment**: [${userStory.implementation.increment}](${userStory.implementation.tasks[0]?.path.replace(/#.*$/, '')})`);
|
|
690
|
+
lines.push('');
|
|
691
|
+
lines.push('**Tasks**:');
|
|
692
|
+
for (const task of userStory.implementation.tasks) {
|
|
693
|
+
lines.push(`- [${task.id}: ${task.title}](${task.path})`);
|
|
694
|
+
}
|
|
695
|
+
lines.push('');
|
|
696
|
+
// Business Rationale
|
|
697
|
+
if (userStory.businessRationale) {
|
|
698
|
+
lines.push('---');
|
|
699
|
+
lines.push('');
|
|
700
|
+
lines.push('## Business Rationale');
|
|
701
|
+
lines.push('');
|
|
702
|
+
lines.push(userStory.businessRationale);
|
|
703
|
+
lines.push('');
|
|
704
|
+
}
|
|
705
|
+
// Related User Stories
|
|
706
|
+
if (userStory.relatedStories.length > 0) {
|
|
707
|
+
lines.push('---');
|
|
708
|
+
lines.push('');
|
|
709
|
+
lines.push('## Related User Stories');
|
|
710
|
+
lines.push('');
|
|
711
|
+
for (const related of userStory.relatedStories) {
|
|
712
|
+
lines.push(`- [${related.id}: ${related.title}](${related.filePath})`);
|
|
713
|
+
}
|
|
714
|
+
lines.push('');
|
|
715
|
+
}
|
|
716
|
+
lines.push('---');
|
|
717
|
+
lines.push('');
|
|
718
|
+
lines.push(`**Status**: ${userStory.status === 'complete' ? '✅' : '⏳'} ${userStory.status.charAt(0).toUpperCase() + userStory.status.slice(1)}`);
|
|
719
|
+
if (userStory.completed) {
|
|
720
|
+
lines.push(`**Completed**: ${userStory.completed}`);
|
|
721
|
+
}
|
|
722
|
+
lines.push('');
|
|
723
|
+
return lines.join('\n');
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Update tasks.md with bidirectional links to user stories (CRITICAL!)
|
|
727
|
+
*
|
|
728
|
+
* This creates bidirectional traceability:
|
|
729
|
+
* - User Story → Tasks (already done in us-*.md files)
|
|
730
|
+
* - Tasks → User Story (NEW - added here)
|
|
731
|
+
*
|
|
732
|
+
* When a task implements a user story, this adds a link in tasks.md:
|
|
733
|
+
* **User Story**: [US-001: Title](../../docs/internal/specs/{project}/{feature}/us-001-*.md)
|
|
734
|
+
*/
|
|
735
|
+
async updateTasksWithUserStoryLinks(incrementId, userStories, epicMapping) {
|
|
736
|
+
const tasksPath = path.join(this.projectRoot, '.specweave', 'increments', incrementId, 'tasks.md');
|
|
737
|
+
// Check if tasks.md exists
|
|
738
|
+
if (!fs.existsSync(tasksPath)) {
|
|
739
|
+
console.log(` ⚠️ tasks.md not found for ${incrementId}, skipping bidirectional link update`);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
try {
|
|
743
|
+
const tasksContent = await fs.readFile(tasksPath, 'utf-8');
|
|
744
|
+
// Parse tasks to create task → user story mapping
|
|
745
|
+
const taskToUSMapping = this.mapTasksToUserStories(tasksContent, userStories);
|
|
746
|
+
if (Object.keys(taskToUSMapping).length === 0) {
|
|
747
|
+
console.log(` ℹ️ No AC-based task-to-US mapping found, skipping bidirectional links`);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
// Update tasks.md content with user story links
|
|
751
|
+
let updatedContent = tasksContent;
|
|
752
|
+
let linksAdded = 0;
|
|
753
|
+
for (const [taskId, userStory] of Object.entries(taskToUSMapping)) {
|
|
754
|
+
// Generate relative path from tasks.md to user story file
|
|
755
|
+
const projectId = epicMapping.featurePath.split('/specs/')[1]?.split('/')[0] || 'default';
|
|
756
|
+
const featureFolder = epicMapping.featureFolder;
|
|
757
|
+
const userStoryFile = this.generateUserStoryFilename(userStory.id, userStory.title);
|
|
758
|
+
const relativePath = `../../docs/internal/specs/${projectId}/${featureFolder}/${userStoryFile}`;
|
|
759
|
+
// Find task section and add link if not already present (supports both ## and ### headings)
|
|
760
|
+
// CRITICAL: Remove 'g' flag to prevent multiple matches of the same task
|
|
761
|
+
const taskPattern = new RegExp(`(^##+ ${taskId}:.*?$\\n)([\\s\\S]*?)(?=^##+ T-|^---$|$)`, 'm');
|
|
762
|
+
// Only replace once per task
|
|
763
|
+
let replaced = false;
|
|
764
|
+
updatedContent = updatedContent.replace(taskPattern, (match, heading, body) => {
|
|
765
|
+
// Prevent multiple replacements
|
|
766
|
+
if (replaced) {
|
|
767
|
+
return match;
|
|
768
|
+
}
|
|
769
|
+
// Check if link already exists
|
|
770
|
+
if (body.includes('**User Story**:')) {
|
|
771
|
+
return match; // Link already exists
|
|
772
|
+
}
|
|
773
|
+
// Insert link right after the heading (before any content)
|
|
774
|
+
const linkLine = `**User Story**: [${userStory.id}: ${userStory.title}](${relativePath})\n\n`;
|
|
775
|
+
replaced = true;
|
|
776
|
+
linksAdded++;
|
|
777
|
+
return heading + linkLine + body;
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
// Write updated tasks.md
|
|
781
|
+
if (linksAdded > 0) {
|
|
782
|
+
await fs.writeFile(tasksPath, updatedContent, 'utf-8');
|
|
783
|
+
console.log(` 🔗 Added ${linksAdded} bidirectional links to tasks.md`);
|
|
784
|
+
}
|
|
785
|
+
else {
|
|
786
|
+
console.log(` ℹ️ All tasks already have user story links`);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
catch (error) {
|
|
790
|
+
console.warn(` ⚠️ Failed to update tasks.md with bidirectional links: ${error}`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Map tasks to user stories using AC-IDs
|
|
795
|
+
*
|
|
796
|
+
* Extracts AC-IDs from tasks (e.g., AC-US1-01) and maps them to user stories (e.g., US-001)
|
|
797
|
+
*/
|
|
798
|
+
mapTasksToUserStories(tasksContent, userStories) {
|
|
799
|
+
const mapping = {};
|
|
800
|
+
// Extract all tasks with their AC-IDs (supports both ## and ### headings)
|
|
801
|
+
const taskPattern = /^##+ (T-\d+):.*?$\n[\s\S]*?\*\*AC\*\*:\s*([^\n]+)/gm;
|
|
802
|
+
let match;
|
|
803
|
+
while ((match = taskPattern.exec(tasksContent)) !== null) {
|
|
804
|
+
const taskId = match[1]; // T-001
|
|
805
|
+
const acList = match[2]; // AC-US1-01, AC-US1-02
|
|
806
|
+
// Extract user story IDs from AC-IDs (AC-US1-01 → US-001)
|
|
807
|
+
const acPattern = /AC-US(\d+)-\d+/g;
|
|
808
|
+
let acMatch;
|
|
809
|
+
while ((acMatch = acPattern.exec(acList)) !== null) {
|
|
810
|
+
const usNumber = acMatch[1]; // "1"
|
|
811
|
+
const usId = `US-${usNumber.padStart(3, '0')}`; // "US-001"
|
|
812
|
+
// Find matching user story
|
|
813
|
+
const userStory = userStories.find(us => us.id === usId);
|
|
814
|
+
if (userStory) {
|
|
815
|
+
mapping[taskId] = userStory;
|
|
816
|
+
break; // One task can only map to one primary user story
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return mapping;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
//# sourceMappingURL=spec-distributor.js.map
|