@eddacraft/anvil-adapters 0.1.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.
Files changed (183) hide show
  1. package/AGENTS.md +180 -0
  2. package/BMAD_ADAPTER_SPEC.md +489 -0
  3. package/LICENSE +14 -0
  4. package/README.md +500 -0
  5. package/dist/aps-markdown/adapter.d.ts +102 -0
  6. package/dist/aps-markdown/adapter.d.ts.map +1 -0
  7. package/dist/aps-markdown/adapter.js +351 -0
  8. package/dist/aps-markdown/index.d.ts +8 -0
  9. package/dist/aps-markdown/index.d.ts.map +1 -0
  10. package/dist/aps-markdown/index.js +7 -0
  11. package/dist/base/file-discovery.d.ts +63 -0
  12. package/dist/base/file-discovery.d.ts.map +1 -0
  13. package/dist/base/file-discovery.js +246 -0
  14. package/dist/base/index.d.ts +10 -0
  15. package/dist/base/index.d.ts.map +1 -0
  16. package/dist/base/index.js +9 -0
  17. package/dist/base/registry.d.ts +155 -0
  18. package/dist/base/registry.d.ts.map +1 -0
  19. package/dist/base/registry.js +227 -0
  20. package/dist/base/testing.d.ts +102 -0
  21. package/dist/base/testing.d.ts.map +1 -0
  22. package/dist/base/testing.js +221 -0
  23. package/dist/base/types.d.ts +255 -0
  24. package/dist/base/types.d.ts.map +1 -0
  25. package/dist/base/types.js +78 -0
  26. package/dist/base/utils.d.ts +127 -0
  27. package/dist/base/utils.d.ts.map +1 -0
  28. package/dist/base/utils.js +254 -0
  29. package/dist/bmad/format-adapter.d.ts +76 -0
  30. package/dist/bmad/format-adapter.d.ts.map +1 -0
  31. package/dist/bmad/format-adapter.js +186 -0
  32. package/dist/bmad/index.d.ts +12 -0
  33. package/dist/bmad/index.d.ts.map +1 -0
  34. package/dist/bmad/index.js +10 -0
  35. package/dist/bmad/parser.d.ts +12 -0
  36. package/dist/bmad/parser.d.ts.map +1 -0
  37. package/dist/bmad/parser.js +181 -0
  38. package/dist/bmad/serializer.d.ts +16 -0
  39. package/dist/bmad/serializer.d.ts.map +1 -0
  40. package/dist/bmad/serializer.js +170 -0
  41. package/dist/bmad/types.d.ts +127 -0
  42. package/dist/bmad/types.d.ts.map +1 -0
  43. package/dist/bmad/types.js +47 -0
  44. package/dist/bmad/utils.d.ts +120 -0
  45. package/dist/bmad/utils.d.ts.map +1 -0
  46. package/dist/bmad/utils.js +480 -0
  47. package/dist/common/index.d.ts +3 -0
  48. package/dist/common/index.d.ts.map +1 -0
  49. package/dist/common/index.js +2 -0
  50. package/dist/common/registry.d.ts +18 -0
  51. package/dist/common/registry.d.ts.map +1 -0
  52. package/dist/common/registry.js +58 -0
  53. package/dist/common/types.d.ts +68 -0
  54. package/dist/common/types.d.ts.map +1 -0
  55. package/dist/common/types.js +12 -0
  56. package/dist/generic/format-adapter.d.ts +64 -0
  57. package/dist/generic/format-adapter.d.ts.map +1 -0
  58. package/dist/generic/format-adapter.js +159 -0
  59. package/dist/generic/index.d.ts +10 -0
  60. package/dist/generic/index.d.ts.map +1 -0
  61. package/dist/generic/index.js +9 -0
  62. package/dist/generic/parser.d.ts +11 -0
  63. package/dist/generic/parser.d.ts.map +1 -0
  64. package/dist/generic/parser.js +106 -0
  65. package/dist/generic/serializer.d.ts +11 -0
  66. package/dist/generic/serializer.d.ts.map +1 -0
  67. package/dist/generic/serializer.js +118 -0
  68. package/dist/generic/types.d.ts +52 -0
  69. package/dist/generic/types.d.ts.map +1 -0
  70. package/dist/generic/types.js +6 -0
  71. package/dist/generic/utils.d.ts +51 -0
  72. package/dist/generic/utils.d.ts.map +1 -0
  73. package/dist/generic/utils.js +232 -0
  74. package/dist/index.d.ts +15 -0
  75. package/dist/index.d.ts.map +1 -0
  76. package/dist/index.js +31 -0
  77. package/dist/speckit/export.d.ts +22 -0
  78. package/dist/speckit/export.d.ts.map +1 -0
  79. package/dist/speckit/export.js +384 -0
  80. package/dist/speckit/format-adapter.d.ts +104 -0
  81. package/dist/speckit/format-adapter.d.ts.map +1 -0
  82. package/dist/speckit/format-adapter.js +488 -0
  83. package/dist/speckit/import-v2.d.ts +33 -0
  84. package/dist/speckit/import-v2.d.ts.map +1 -0
  85. package/dist/speckit/import-v2.js +361 -0
  86. package/dist/speckit/import.d.ts +16 -0
  87. package/dist/speckit/import.d.ts.map +1 -0
  88. package/dist/speckit/import.js +247 -0
  89. package/dist/speckit/index.d.ts +5 -0
  90. package/dist/speckit/index.d.ts.map +1 -0
  91. package/dist/speckit/index.js +4 -0
  92. package/dist/speckit/parser.d.ts +28 -0
  93. package/dist/speckit/parser.d.ts.map +1 -0
  94. package/dist/speckit/parser.js +283 -0
  95. package/dist/speckit/parsers/plan-parser.d.ts +71 -0
  96. package/dist/speckit/parsers/plan-parser.d.ts.map +1 -0
  97. package/dist/speckit/parsers/plan-parser.js +216 -0
  98. package/dist/speckit/parsers/spec-parser.d.ts +67 -0
  99. package/dist/speckit/parsers/spec-parser.d.ts.map +1 -0
  100. package/dist/speckit/parsers/spec-parser.js +255 -0
  101. package/dist/speckit/parsers/tasks-parser.d.ts +57 -0
  102. package/dist/speckit/parsers/tasks-parser.d.ts.map +1 -0
  103. package/dist/speckit/parsers/tasks-parser.js +157 -0
  104. package/package.json +23 -0
  105. package/project.json +29 -0
  106. package/src/__tests__/adapter-edge-cases.test.ts +937 -0
  107. package/src/__tests__/bmad-format-adapter.test.ts +1470 -0
  108. package/src/__tests__/fixtures/aps/expected-output.json +83 -0
  109. package/src/__tests__/fixtures/bmad/invalid-malformed-yaml.md +16 -0
  110. package/src/__tests__/fixtures/bmad/invalid-no-requirements.md +23 -0
  111. package/src/__tests__/fixtures/bmad/invalid-only-yaml.md +16 -0
  112. package/src/__tests__/fixtures/bmad/invalid-too-short.md +3 -0
  113. package/src/__tests__/fixtures/bmad/invalid-wrong-format.md +40 -0
  114. package/src/__tests__/fixtures/bmad/valid-agent.md +27 -0
  115. package/src/__tests__/fixtures/bmad/valid-architecture.md +116 -0
  116. package/src/__tests__/fixtures/bmad/valid-complex-prd.md +161 -0
  117. package/src/__tests__/fixtures/bmad/valid-epic.md +73 -0
  118. package/src/__tests__/fixtures/bmad/valid-minimal-prd.md +19 -0
  119. package/src/__tests__/fixtures/bmad/valid-prd.md +107 -0
  120. package/src/__tests__/fixtures/bmad/valid-story.md +107 -0
  121. package/src/__tests__/fixtures/bmad/valid-task.md +79 -0
  122. package/src/__tests__/fixtures/bmad/valid-v6-prd.md +35 -0
  123. package/src/__tests__/fixtures/generic/plan-detailed.md +39 -0
  124. package/src/__tests__/fixtures/generic/prd-simple.md +27 -0
  125. package/src/__tests__/fixtures/generic/rfc-example.md +26 -0
  126. package/src/__tests__/fixtures/generic/todo-list.md +23 -0
  127. package/src/__tests__/fixtures/speckit/sample-plan.md +63 -0
  128. package/src/__tests__/fixtures/speckit/sample-spec-namespaced.md +50 -0
  129. package/src/__tests__/fixtures/speckit/sample-spec.md +105 -0
  130. package/src/__tests__/fixtures/speckit/sample-tasks.md +87 -0
  131. package/src/__tests__/fixtures/speckit-official/auth-feature/plan.md +272 -0
  132. package/src/__tests__/fixtures/speckit-official/auth-feature/spec.md +149 -0
  133. package/src/__tests__/fixtures/speckit-official/auth-feature/tasks.md +169 -0
  134. package/src/__tests__/generic-format-adapter.test.ts +398 -0
  135. package/src/__tests__/speckit-export.test.ts +233 -0
  136. package/src/__tests__/speckit-format-adapter.test.ts +832 -0
  137. package/src/__tests__/speckit-import-v2.test.ts +253 -0
  138. package/src/__tests__/speckit-import.test.ts +209 -0
  139. package/src/__tests__/speckit-parser.test.ts +219 -0
  140. package/src/__tests__/speckit-spec-parser.test.ts +120 -0
  141. package/src/aps-markdown/__tests__/__fixtures__/simple-leaf.aps.md +17 -0
  142. package/src/aps-markdown/__tests__/adapter.test.ts +393 -0
  143. package/src/aps-markdown/adapter.ts +455 -0
  144. package/src/aps-markdown/index.ts +8 -0
  145. package/src/base/__tests__/registry.test.ts +515 -0
  146. package/src/base/file-discovery.ts +305 -0
  147. package/src/base/index.ts +10 -0
  148. package/src/base/registry.ts +263 -0
  149. package/src/base/testing.ts +334 -0
  150. package/src/base/types.ts +342 -0
  151. package/src/base/utils.ts +306 -0
  152. package/src/bmad/format-adapter.ts +227 -0
  153. package/src/bmad/index.ts +21 -0
  154. package/src/bmad/parser.ts +224 -0
  155. package/src/bmad/serializer.ts +206 -0
  156. package/src/bmad/types.ts +135 -0
  157. package/src/bmad/utils.ts +575 -0
  158. package/src/common/index.ts +2 -0
  159. package/src/common/registry.ts +72 -0
  160. package/src/common/types.ts +84 -0
  161. package/src/generic/__tests__/serializer.test.ts +167 -0
  162. package/src/generic/format-adapter.ts +200 -0
  163. package/src/generic/index.ts +11 -0
  164. package/src/generic/parser.ts +129 -0
  165. package/src/generic/serializer.ts +134 -0
  166. package/src/generic/types.ts +53 -0
  167. package/src/generic/utils.ts +270 -0
  168. package/src/index.ts +48 -0
  169. package/src/speckit/export.ts +489 -0
  170. package/src/speckit/format-adapter.ts +595 -0
  171. package/src/speckit/import-v2.ts +445 -0
  172. package/src/speckit/import.ts +305 -0
  173. package/src/speckit/index.ts +4 -0
  174. package/src/speckit/parser.ts +351 -0
  175. package/src/speckit/parsers/plan-parser.ts +342 -0
  176. package/src/speckit/parsers/spec-parser.ts +379 -0
  177. package/src/speckit/parsers/tasks-parser.ts +246 -0
  178. package/tsconfig.json +26 -0
  179. package/tsconfig.lib.json +21 -0
  180. package/tsconfig.lib.tsbuildinfo +1 -0
  181. package/tsconfig.spec.json +9 -0
  182. package/tsconfig.tsbuildinfo +1 -0
  183. package/vitest.config.ts +14 -0
@@ -0,0 +1,575 @@
1
+ /**
2
+ * BMAD Adapter Utilities
3
+ *
4
+ * Helper functions for BMAD format parsing and serialization.
5
+ */
6
+
7
+ import {
8
+ BMADFrontMatter,
9
+ BMADRequirement,
10
+ BMADUserStory,
11
+ BMADChangeLogEntry,
12
+ RequirementType,
13
+ BMADDocumentType,
14
+ DetectionIndicators,
15
+ BMAD_FOLDERS,
16
+ } from './types.js';
17
+ import type { PathDetectionHint } from '../base/types.js';
18
+
19
+ /**
20
+ * Analyze a file path for BMAD folder structure indicators
21
+ *
22
+ * Detects both v6 (`_bmad`, `_config`) and legacy (`.bmad`, `_cfg`) paths.
23
+ *
24
+ * @param hint - Path detection hint with file path and directory info
25
+ * @returns Object indicating which BMAD path patterns were found
26
+ */
27
+ export function analyzePath(hint: PathDetectionHint): {
28
+ isBmadFolder: boolean;
29
+ isConfigFolder: boolean;
30
+ } {
31
+ const { filePath, parentDirs } = hint;
32
+ const normalizedPath = filePath.replace(/\\/g, '/');
33
+ const allDirs = parentDirs ?? [];
34
+
35
+ const bmadDirs: readonly string[] = [BMAD_FOLDERS.PROJECT, BMAD_FOLDERS.PROJECT_LEGACY];
36
+ const configDirs: readonly string[] = [BMAD_FOLDERS.CONFIG, BMAD_FOLDERS.CONFIG_LEGACY];
37
+
38
+ const isBmadFolder =
39
+ bmadDirs.some((d) => {
40
+ const pattern = new RegExp(`(?:^|/)${escapeRegExp(d)}(?:/|$)`);
41
+ return pattern.test(normalizedPath);
42
+ }) || allDirs.some((d) => bmadDirs.includes(d));
43
+
44
+ const isConfigFolder =
45
+ configDirs.some((d) => {
46
+ const pattern = new RegExp(`(?:^|/)${escapeRegExp(d)}(?:/|$)`);
47
+ return pattern.test(normalizedPath);
48
+ }) || allDirs.some((d) => configDirs.includes(d));
49
+
50
+ return { isBmadFolder, isConfigFolder };
51
+ }
52
+
53
+ /**
54
+ * Expand BMAD template variables in content
55
+ *
56
+ * Supports both legacy underscore syntax `{project_root}` and
57
+ * v6 hyphenated syntax `{project-root}`.
58
+ *
59
+ * @param content - Content with variable placeholders
60
+ * @param variables - Variable values to substitute
61
+ * @returns Content with variables expanded
62
+ */
63
+ export function expandVariables(content: string, variables: Record<string, string>): string {
64
+ let result = content;
65
+
66
+ for (const [key, value] of Object.entries(variables)) {
67
+ // Support both underscore and hyphenated forms
68
+ const underscoreKey = key.replace(/-/g, '_');
69
+ const hyphenKey = key.replace(/_/g, '-');
70
+
71
+ result = result
72
+ .replace(new RegExp(`\\{${escapeRegExp(underscoreKey)}\\}`, 'g'), () => value)
73
+ .replace(new RegExp(`\\{${escapeRegExp(hyphenKey)}\\}`, 'g'), () => value);
74
+ }
75
+
76
+ return result;
77
+ }
78
+
79
+ /**
80
+ * Check if content contains hyphenated variable syntax (v6)
81
+ *
82
+ * @param content - Content to check
83
+ * @returns True if hyphenated variables are found
84
+ */
85
+ export function hasHyphenatedVariables(content: string): boolean {
86
+ return /\{[a-z]+-[a-z]+(?:-[a-z]+)*\}/i.test(content);
87
+ }
88
+
89
+ function escapeRegExp(str: string): string {
90
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
91
+ }
92
+
93
+ /**
94
+ * Parse a YAML boolean value
95
+ *
96
+ * Handles YAML 1.1 boolean forms: true/false, yes/no, on/off.
97
+ *
98
+ * @param value - String value from YAML
99
+ * @returns Boolean or undefined if not a boolean value
100
+ */
101
+ export function parseYamlBoolean(value: string): boolean | undefined {
102
+ const lower = value.toLowerCase().trim();
103
+ if (['true', 'yes', 'on'].includes(lower)) return true;
104
+ if (['false', 'no', 'off'].includes(lower)) return false;
105
+ return undefined;
106
+ }
107
+
108
+ /**
109
+ * Extract YAML front-matter from markdown content
110
+ *
111
+ * @param content - Markdown content
112
+ * @returns Parsed front-matter or null
113
+ */
114
+ export function extractFrontMatter(content: string): BMADFrontMatter | null {
115
+ const frontMatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n/;
116
+ const match = content.match(frontMatterRegex);
117
+
118
+ if (!match) {
119
+ return null;
120
+ }
121
+
122
+ const yamlContent = match[1];
123
+ const frontMatter: BMADFrontMatter = {};
124
+
125
+ // Simple YAML parser (handles basic key: value pairs)
126
+ const lines = yamlContent.split('\n');
127
+
128
+ for (const line of lines) {
129
+ const trimmed = line.trim();
130
+ if (!trimmed || trimmed.startsWith('#')) continue;
131
+
132
+ // Handle key: value
133
+ const keyValueMatch = trimmed.match(/^(\w+):\s*(.*)$/);
134
+ if (keyValueMatch) {
135
+ const [, key, value] = keyValueMatch;
136
+
137
+ // Remove quotes
138
+ let cleanValue = value.replace(/^["']|["']$/g, '').trim();
139
+ // Handle template variables
140
+ cleanValue = cleanValue.replace(/\{\{.*?\}\}/g, '');
141
+
142
+ if (key === 'variables') {
143
+ frontMatter[key] = {};
144
+ } else if (key === 'hasSidecar') {
145
+ // Parse boolean value
146
+ const boolVal = parseYamlBoolean(cleanValue);
147
+ if (boolVal !== undefined) {
148
+ frontMatter.hasSidecar = boolVal;
149
+ }
150
+ } else {
151
+ frontMatter[key as keyof BMADFrontMatter] = cleanValue as never;
152
+ }
153
+ }
154
+ }
155
+
156
+ return frontMatter;
157
+ }
158
+
159
+ /**
160
+ * Extract requirements from content
161
+ *
162
+ * @param content - Document content
163
+ * @returns Array of requirements
164
+ */
165
+ export function extractRequirements(content: string): BMADRequirement[] {
166
+ const requirements: BMADRequirement[] = [];
167
+ const lines = content.split('\n');
168
+
169
+ for (let i = 0; i < lines.length; i++) {
170
+ const line = lines[i];
171
+ const trimmed = line.trim();
172
+
173
+ // Match FR-01, NFR-01, US-01 patterns (optionally prefixed by list markers)
174
+ const reqMatch = trimmed.match(/^(?:[-*+]\s+)?(FR|NFR|US)-(\d{2}):\s*(.+)$/);
175
+ if (reqMatch) {
176
+ const [, typeStr, numStr, description] = reqMatch;
177
+ requirements.push({
178
+ type: typeStr as RequirementType,
179
+ id: `${typeStr}-${numStr}`,
180
+ number: parseInt(numStr, 10),
181
+ description,
182
+ line: i + 1,
183
+ });
184
+ }
185
+ }
186
+
187
+ return requirements;
188
+ }
189
+
190
+ /**
191
+ * Extract user stories from content
192
+ *
193
+ * @param content - Document content
194
+ * @returns Array of user stories
195
+ */
196
+ export function extractUserStories(content: string): BMADUserStory[] {
197
+ const stories: BMADUserStory[] = [];
198
+ const lines = content.split('\n');
199
+
200
+ for (let i = 0; i < lines.length; i++) {
201
+ const line = lines[i];
202
+
203
+ // Match US-01: Title format
204
+ const storyMatch = line.trim().match(/^(?:[-*+]\s+)?(US-\d{2}):\s*(.+)$/);
205
+ if (!storyMatch) continue;
206
+
207
+ const [, id, title] = storyMatch;
208
+ const story: BMADUserStory = {
209
+ id,
210
+ title,
211
+ line: i + 1,
212
+ };
213
+
214
+ // Look for "As a... I want... so that..." pattern in following lines
215
+ let j = i + 1;
216
+ while (j < lines.length && j < i + 10) {
217
+ const storyLine = lines[j].trim();
218
+
219
+ const asMatch = storyLine.match(/^As an?\s+(.+?),?\s*$/i);
220
+ if (asMatch) {
221
+ story.userType = asMatch[1];
222
+ }
223
+
224
+ const wantMatch = storyLine.match(/^I want\s+(.+?),?\s*$/i);
225
+ if (wantMatch) {
226
+ story.action = wantMatch[1];
227
+ }
228
+
229
+ const soThatMatch = storyLine.match(/^so that\s+(.+?)\.?\s*$/i);
230
+ if (soThatMatch) {
231
+ story.benefit = soThatMatch[1];
232
+ }
233
+
234
+ // Look for acceptance criteria
235
+ if (storyLine.match(/^acceptance criteria:?$/i)) {
236
+ const criteria: string[] = [];
237
+ let k = j + 1;
238
+ while (k < lines.length && k < j + 20) {
239
+ const criteriaLine = lines[k].trim();
240
+ const criteriaMatch = criteriaLine.match(/^\d+\.\s+(.+)$/);
241
+ if (criteriaMatch) {
242
+ criteria.push(criteriaMatch[1]);
243
+ } else if (criteriaLine && !criteriaLine.match(/^\d+\./)) {
244
+ break;
245
+ }
246
+ k++;
247
+ }
248
+ if (criteria.length > 0) {
249
+ story.acceptanceCriteria = criteria;
250
+ }
251
+ break;
252
+ }
253
+
254
+ j++;
255
+ }
256
+
257
+ stories.push(story);
258
+ }
259
+
260
+ return stories;
261
+ }
262
+
263
+ /**
264
+ * Extract change log entries from content
265
+ *
266
+ * @param content - Document content
267
+ * @returns Array of change log entries
268
+ */
269
+ export function extractChangeLog(content: string): BMADChangeLogEntry[] {
270
+ const entries: BMADChangeLogEntry[] = [];
271
+ const lines = content.split('\n');
272
+
273
+ let inTable = false;
274
+ for (const line of lines) {
275
+ // Detect table header
276
+ if (line.match(/\|\s*Date\s*\|\s*Version\s*\|\s*Description\s*\|\s*Author\s*\|/i)) {
277
+ inTable = true;
278
+ continue;
279
+ }
280
+
281
+ // Skip separator row
282
+ if (inTable && line.match(/\|[\s:-]+\|/)) {
283
+ continue;
284
+ }
285
+
286
+ // Parse table rows
287
+ if (inTable) {
288
+ const rowMatch = line.match(/\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|/);
289
+ if (rowMatch) {
290
+ const [, date, version, description, author] = rowMatch;
291
+ entries.push({
292
+ date: date.trim(),
293
+ version: version.trim(),
294
+ description: description.trim(),
295
+ author: author.trim(),
296
+ });
297
+ } else if (line.trim() && !line.startsWith('|')) {
298
+ // End of table
299
+ break;
300
+ }
301
+ }
302
+ }
303
+
304
+ return entries;
305
+ }
306
+
307
+ /**
308
+ * Identify document type from content
309
+ *
310
+ * @param content - Document content
311
+ * @param frontMatter - Parsed front-matter
312
+ * @returns Document type
313
+ */
314
+ export function identifyDocumentType(
315
+ content: string,
316
+ frontMatter?: BMADFrontMatter | null
317
+ ): BMADDocumentType {
318
+ // Check front-matter
319
+ if (frontMatter?.name) {
320
+ if (/product requirements/i.test(frontMatter.name)) {
321
+ return BMADDocumentType.PRD;
322
+ }
323
+ if (/architecture/i.test(frontMatter.name)) {
324
+ return BMADDocumentType.ARCHITECTURE;
325
+ }
326
+ if (/\bagent\b/i.test(frontMatter.name)) {
327
+ return BMADDocumentType.AGENT;
328
+ }
329
+ }
330
+
331
+ // v6: hasSidecar field is a strong agent indicator
332
+ if (frontMatter?.hasSidecar !== undefined) {
333
+ return BMADDocumentType.AGENT;
334
+ }
335
+
336
+ // Check content
337
+ if (/(Product Requirements Document|PRD)/i.test(content)) {
338
+ return BMADDocumentType.PRD;
339
+ }
340
+ if (/Architecture Document/i.test(content)) {
341
+ return BMADDocumentType.ARCHITECTURE;
342
+ }
343
+ if (/Epic:/i.test(content) || /Epic Goal/i.test(content)) {
344
+ return BMADDocumentType.EPIC;
345
+ }
346
+ if (/As an?\s+[^\n]{1,200},\s*\nI want\s+[^\n]{1,200},\s*\nso that/i.test(content)) {
347
+ return BMADDocumentType.STORY;
348
+ }
349
+
350
+ return BMADDocumentType.UNKNOWN;
351
+ }
352
+
353
+ /**
354
+ * Analyze content for detection indicators
355
+ *
356
+ * @param content - Document content
357
+ * @param hint - Optional path detection hint
358
+ * @returns Detection indicators
359
+ */
360
+ export function analyzeContent(content: string, hint?: PathDetectionHint): DetectionIndicators {
361
+ const frontMatter = extractFrontMatter(content);
362
+ const requirements = extractRequirements(content);
363
+
364
+ const pathAnalysis = hint ? analyzePath(hint) : { isBmadFolder: false, isConfigFolder: false };
365
+
366
+ return {
367
+ hasYamlFrontMatter: frontMatter !== null && Object.keys(frontMatter).length > 0,
368
+ hasFunctionalRequirements: requirements.some((r) => r.type === RequirementType.FUNCTIONAL),
369
+ hasNonFunctionalRequirements: requirements.some(
370
+ (r) => r.type === RequirementType.NON_FUNCTIONAL
371
+ ),
372
+ hasUserStories: requirements.some((r) => r.type === RequirementType.USER_STORY),
373
+ hasUserStoryFormat: /As an?\s+[^\n]{1,200}[,\s]+I want\s+[^\n]{1,200}[,\s]+so that/i.test(
374
+ content
375
+ ),
376
+ hasChangeLogTable: /\|\s*Date\s*\|\s*Version\s*\|\s*Description\s*\|\s*Author\s*\|/i.test(
377
+ content
378
+ ),
379
+ hasDocumentTitle: /(Product Requirements|Architecture) Document/i.test(content),
380
+ requirementCount: requirements.length,
381
+ hasBmadFolderPath: pathAnalysis.isBmadFolder,
382
+ hasBmadConfigPath: pathAnalysis.isConfigFolder,
383
+ hasHasSidecar: frontMatter?.hasSidecar !== undefined,
384
+ hasHyphenatedVariables: hasHyphenatedVariables(content),
385
+ };
386
+ }
387
+
388
+ /**
389
+ * Calculate detection confidence score
390
+ *
391
+ * @param indicators - Detection indicators
392
+ * @returns Confidence score (0-100)
393
+ */
394
+ export function calculateConfidenceScore(indicators: DetectionIndicators): number {
395
+ let score = 0;
396
+
397
+ // YAML front-matter (30 points)
398
+ if (indicators.hasYamlFrontMatter) {
399
+ score += 30;
400
+ }
401
+
402
+ // Requirement identifiers (25 points)
403
+ if (
404
+ indicators.hasFunctionalRequirements ||
405
+ indicators.hasNonFunctionalRequirements ||
406
+ indicators.hasUserStories
407
+ ) {
408
+ score += 25;
409
+ }
410
+
411
+ // User story format (20 points)
412
+ if (indicators.hasUserStoryFormat) {
413
+ score += 20;
414
+ }
415
+
416
+ // Change log table (15 points)
417
+ if (indicators.hasChangeLogTable) {
418
+ score += 15;
419
+ }
420
+
421
+ // Document title (10 points)
422
+ if (indicators.hasDocumentTitle) {
423
+ score += 10;
424
+ }
425
+
426
+ // v6: BMAD folder path (20 points bonus)
427
+ if (indicators.hasBmadFolderPath) {
428
+ score += 20;
429
+ }
430
+
431
+ // v6: Config folder path (5 points bonus)
432
+ if (indicators.hasBmadConfigPath) {
433
+ score += 5;
434
+ }
435
+
436
+ // v6: hasSidecar field (15 points bonus)
437
+ if (indicators.hasHasSidecar) {
438
+ score += 15;
439
+ }
440
+
441
+ // v6: Hyphenated variables like {{var-name}} (10 points bonus)
442
+ if (indicators.hasHyphenatedVariables) {
443
+ score += 10;
444
+ }
445
+
446
+ return Math.min(100, score);
447
+ }
448
+
449
+ /**
450
+ * Build detection reason message
451
+ *
452
+ * @param indicators - Detection indicators
453
+ * @returns Reason message
454
+ */
455
+ export function buildDetectionReason(indicators: DetectionIndicators): string {
456
+ const reasons: string[] = [];
457
+
458
+ if (indicators.hasYamlFrontMatter) {
459
+ reasons.push('yaml-frontmatter');
460
+ }
461
+ if (
462
+ indicators.hasFunctionalRequirements ||
463
+ indicators.hasNonFunctionalRequirements ||
464
+ indicators.hasUserStories
465
+ ) {
466
+ reasons.push(`${indicators.requirementCount} requirements`);
467
+ }
468
+ if (indicators.hasUserStoryFormat) {
469
+ reasons.push('user-story-format');
470
+ }
471
+ if (indicators.hasChangeLogTable) {
472
+ reasons.push('change-log-table');
473
+ }
474
+ if (indicators.hasDocumentTitle) {
475
+ reasons.push('document-title');
476
+ }
477
+ if (indicators.hasBmadFolderPath) {
478
+ reasons.push('bmad-folder');
479
+ }
480
+ if (indicators.hasBmadConfigPath) {
481
+ reasons.push('bmad-config');
482
+ }
483
+ if (indicators.hasHasSidecar) {
484
+ reasons.push('has-sidecar');
485
+ }
486
+ if (indicators.hasHyphenatedVariables) {
487
+ reasons.push('hyphenated-variables');
488
+ }
489
+
490
+ return reasons.length > 0 ? reasons.join(', ') : 'no strong indicators';
491
+ }
492
+
493
+ /**
494
+ * Extract document title from content
495
+ *
496
+ * @param content - Document content
497
+ * @returns Title or null
498
+ */
499
+ export function extractTitle(content: string): string | null {
500
+ const lines = content.split('\n');
501
+
502
+ for (const line of lines) {
503
+ const trimmed = line.trim();
504
+ // Match h1 header
505
+ const h1Match = trimmed.match(/^#\s+(.+)$/);
506
+ if (h1Match) {
507
+ return h1Match[1];
508
+ }
509
+ }
510
+
511
+ return null;
512
+ }
513
+
514
+ /**
515
+ * Extract intent/summary from document
516
+ *
517
+ * @param content - Document content
518
+ * @param docType - Document type
519
+ * @returns Intent description
520
+ */
521
+ export function extractIntent(content: string, docType: BMADDocumentType): string {
522
+ const lines = content.split('\n');
523
+ let inTargetSection = false;
524
+ const intentLines: string[] = [];
525
+
526
+ // Section headers to look for based on document type
527
+ const targetSections: Record<string, string[]> = {
528
+ [BMADDocumentType.PRD]: ['Executive Summary', 'Product Vision', 'Overview'],
529
+ [BMADDocumentType.ARCHITECTURE]: ['Technical Summary', 'Overview'],
530
+ [BMADDocumentType.EPIC]: ['Epic Goal', 'Goal', 'Overview'],
531
+ [BMADDocumentType.STORY]: ['Description', 'Story'],
532
+ [BMADDocumentType.AGENT]: ['Purpose', 'Role', 'Overview'],
533
+ [BMADDocumentType.UNKNOWN]: ['Overview', 'Summary'],
534
+ };
535
+
536
+ const sections = targetSections[docType] ?? targetSections[BMADDocumentType.UNKNOWN];
537
+
538
+ for (let i = 0; i < lines.length; i++) {
539
+ const line = lines[i];
540
+ const trimmed = line.trim();
541
+
542
+ // Check if this is a target section header
543
+ for (const section of sections) {
544
+ if (trimmed.match(new RegExp(`^#{1,3}\\s+${section}\\s*$`, 'i'))) {
545
+ inTargetSection = true;
546
+ continue;
547
+ }
548
+ }
549
+
550
+ // If in target section, collect lines until next header
551
+ if (inTargetSection) {
552
+ if (trimmed.match(/^#{1,3}\s+/)) {
553
+ // Hit another header, stop
554
+ break;
555
+ }
556
+ if (trimmed && !trimmed.startsWith('#')) {
557
+ intentLines.push(trimmed);
558
+ }
559
+ }
560
+ }
561
+
562
+ // Return first paragraph or first 2 sentences
563
+ const intent = intentLines.join(' ').trim();
564
+ if (intent) {
565
+ const sentences = intent.match(/[^.!?]+[.!?]+/g);
566
+ if (sentences && sentences.length > 0) {
567
+ return sentences.slice(0, 2).join(' ').trim();
568
+ }
569
+ return intent.substring(0, 200).trim() + (intent.length > 200 ? '...' : '');
570
+ }
571
+
572
+ // Fallback: use title or first non-empty line
573
+ const title = extractTitle(content);
574
+ return title || 'BMAD document';
575
+ }
@@ -0,0 +1,2 @@
1
+ export * from './types.js';
2
+ export * from './registry.js';
@@ -0,0 +1,72 @@
1
+ import type { SpecToolAdapter, ExternalSpec } from './types.js';
2
+
3
+ export class AdapterRegistry {
4
+ private static instance: AdapterRegistry;
5
+ private adapters: Map<string, SpecToolAdapter> = new Map();
6
+
7
+ private constructor() {}
8
+
9
+ static getInstance(): AdapterRegistry {
10
+ if (!AdapterRegistry.instance) {
11
+ AdapterRegistry.instance = new AdapterRegistry();
12
+ }
13
+ return AdapterRegistry.instance;
14
+ }
15
+
16
+ register(adapter: SpecToolAdapter): void {
17
+ if (this.adapters.has(adapter.name)) {
18
+ throw new Error(`Adapter '${adapter.name}' is already registered`);
19
+ }
20
+ this.adapters.set(adapter.name, adapter);
21
+ }
22
+
23
+ unregister(name: string): void {
24
+ this.adapters.delete(name);
25
+ }
26
+
27
+ getAdapter(name: string): SpecToolAdapter | undefined {
28
+ return this.adapters.get(name);
29
+ }
30
+
31
+ getAdapterForFormat(format: string): SpecToolAdapter | undefined {
32
+ for (const adapter of this.adapters.values()) {
33
+ if (adapter.canImport(format)) {
34
+ return adapter;
35
+ }
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ listAdapters(): ReadonlyArray<SpecToolAdapter> {
41
+ return Array.from(this.adapters.values());
42
+ }
43
+
44
+ listAdapterNames(): ReadonlyArray<string> {
45
+ return Array.from(this.adapters.keys());
46
+ }
47
+
48
+ listSupportedFormats(): ReadonlyArray<string> {
49
+ const formats = new Set<string>();
50
+ for (const adapter of this.adapters.values()) {
51
+ for (const format of adapter.supportedFormats) {
52
+ formats.add(format);
53
+ }
54
+ }
55
+ return Array.from(formats);
56
+ }
57
+
58
+ clear(): void {
59
+ this.adapters.clear();
60
+ }
61
+
62
+ detectFormat(spec: ExternalSpec): string | undefined {
63
+ for (const adapter of this.adapters.values()) {
64
+ if (adapter.canImport(spec.format)) {
65
+ return adapter.name;
66
+ }
67
+ }
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ export const registry = AdapterRegistry.getInstance();