specweave 0.33.2 → 0.33.3

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 (56) hide show
  1. package/CLAUDE.md +56 -0
  2. package/dist/plugins/specweave-ado/lib/per-us-sync.d.ts +120 -0
  3. package/dist/plugins/specweave-ado/lib/per-us-sync.d.ts.map +1 -0
  4. package/dist/plugins/specweave-ado/lib/per-us-sync.js +276 -0
  5. package/dist/plugins/specweave-ado/lib/per-us-sync.js.map +1 -0
  6. package/dist/plugins/specweave-github/lib/github-client-v2.d.ts +4 -1
  7. package/dist/plugins/specweave-github/lib/github-client-v2.d.ts.map +1 -1
  8. package/dist/plugins/specweave-github/lib/github-client-v2.js +13 -3
  9. package/dist/plugins/specweave-github/lib/github-client-v2.js.map +1 -1
  10. package/dist/plugins/specweave-github/lib/per-us-sync.d.ts +97 -0
  11. package/dist/plugins/specweave-github/lib/per-us-sync.d.ts.map +1 -0
  12. package/dist/plugins/specweave-github/lib/per-us-sync.js +274 -0
  13. package/dist/plugins/specweave-github/lib/per-us-sync.js.map +1 -0
  14. package/dist/plugins/specweave-jira/lib/per-us-sync.d.ts +113 -0
  15. package/dist/plugins/specweave-jira/lib/per-us-sync.d.ts.map +1 -0
  16. package/dist/plugins/specweave-jira/lib/per-us-sync.js +254 -0
  17. package/dist/plugins/specweave-jira/lib/per-us-sync.js.map +1 -0
  18. package/dist/src/core/config/config-manager.d.ts.map +1 -1
  19. package/dist/src/core/config/config-manager.js +58 -0
  20. package/dist/src/core/config/config-manager.js.map +1 -1
  21. package/dist/src/core/config/types.d.ts +80 -0
  22. package/dist/src/core/config/types.d.ts.map +1 -1
  23. package/dist/src/core/config/types.js.map +1 -1
  24. package/dist/src/core/living-docs/cross-project-sync.d.ts +87 -15
  25. package/dist/src/core/living-docs/cross-project-sync.d.ts.map +1 -1
  26. package/dist/src/core/living-docs/cross-project-sync.js +147 -28
  27. package/dist/src/core/living-docs/cross-project-sync.js.map +1 -1
  28. package/dist/src/core/living-docs/living-docs-sync.d.ts.map +1 -1
  29. package/dist/src/core/living-docs/living-docs-sync.js +26 -22
  30. package/dist/src/core/living-docs/living-docs-sync.js.map +1 -1
  31. package/dist/src/core/living-docs/types.d.ts +24 -3
  32. package/dist/src/core/living-docs/types.d.ts.map +1 -1
  33. package/dist/src/core/types/config.d.ts +79 -0
  34. package/dist/src/core/types/config.d.ts.map +1 -1
  35. package/dist/src/core/types/config.js.map +1 -1
  36. package/dist/src/sync/sync-coordinator.d.ts +20 -0
  37. package/dist/src/sync/sync-coordinator.d.ts.map +1 -1
  38. package/dist/src/sync/sync-coordinator.js +258 -33
  39. package/dist/src/sync/sync-coordinator.js.map +1 -1
  40. package/dist/src/utils/project-resolver.d.ts +156 -0
  41. package/dist/src/utils/project-resolver.d.ts.map +1 -0
  42. package/dist/src/utils/project-resolver.js +587 -0
  43. package/dist/src/utils/project-resolver.js.map +1 -0
  44. package/package.json +1 -1
  45. package/plugins/specweave/hooks/hooks.json +10 -0
  46. package/plugins/specweave/hooks/user-prompt-submit.sh +105 -3
  47. package/plugins/specweave/hooks/v2/guards/per-us-project-validator.sh +281 -0
  48. package/plugins/specweave/hooks/v2/handlers/living-specs-handler.sh +29 -0
  49. package/plugins/specweave-ado/lib/per-us-sync.js +247 -0
  50. package/plugins/specweave-ado/lib/per-us-sync.ts +410 -0
  51. package/plugins/specweave-github/lib/github-client-v2.js +10 -3
  52. package/plugins/specweave-github/lib/github-client-v2.ts +15 -3
  53. package/plugins/specweave-github/lib/per-us-sync.js +241 -0
  54. package/plugins/specweave-github/lib/per-us-sync.ts +375 -0
  55. package/plugins/specweave-jira/lib/per-us-sync.js +224 -0
  56. package/plugins/specweave-jira/lib/per-us-sync.ts +366 -0
@@ -0,0 +1,247 @@
1
+ import { consoleLogger } from "../../../src/utils/logger.js";
2
+ class PerUSAdoSync {
3
+ constructor(adoClient, projectMappings, options = {}) {
4
+ this.adoClient = adoClient;
5
+ this.projectMappings = projectMappings;
6
+ this.logger = options.logger ?? consoleLogger;
7
+ }
8
+ /**
9
+ * Sync all user stories to their respective ADO projects
10
+ *
11
+ * @param userStories - User stories with explicit project/board fields
12
+ * @param featureId - Feature ID (e.g., "FS-137")
13
+ * @param options - Sync options
14
+ */
15
+ async syncUserStories(userStories, featureId, options = {}) {
16
+ const synced = [];
17
+ const failed = [];
18
+ const externalRefs = {};
19
+ const groups = this.groupByProject(userStories, options.defaultProject);
20
+ this.logger.log(`\u{1F4E1} Per-US ADO Sync: ${userStories.length} USs across ${groups.size} projects`);
21
+ for (const [projectId, stories] of groups) {
22
+ const mapping = this.projectMappings[projectId]?.ado;
23
+ if (!mapping) {
24
+ this.logger.warn(` \u26A0\uFE0F No ADO mapping for project "${projectId}" - skipping ${stories.length} USs`);
25
+ for (const story of stories) {
26
+ failed.push({
27
+ usId: story.id,
28
+ projectId,
29
+ adoProject: "N/A",
30
+ areaPath: "",
31
+ workItemId: 0,
32
+ url: "",
33
+ action: "skipped",
34
+ error: `No ADO mapping for project "${projectId}"`
35
+ });
36
+ }
37
+ continue;
38
+ }
39
+ for (const story of stories) {
40
+ try {
41
+ const result = await this.syncUserStory(story, mapping, featureId, options);
42
+ synced.push({
43
+ ...result,
44
+ projectId
45
+ });
46
+ if (!options.dryRun && result.action !== "skipped") {
47
+ externalRefs[story.id] = {
48
+ ado: {
49
+ provider: "ado",
50
+ issueNumber: result.workItemId,
51
+ url: result.url,
52
+ targetProject: projectId,
53
+ lastSynced: (/* @__PURE__ */ new Date()).toISOString()
54
+ }
55
+ };
56
+ }
57
+ } catch (error) {
58
+ failed.push({
59
+ usId: story.id,
60
+ projectId,
61
+ adoProject: mapping.project,
62
+ areaPath: mapping.areaPath || "",
63
+ workItemId: 0,
64
+ url: "",
65
+ action: "skipped",
66
+ error: error instanceof Error ? error.message : String(error)
67
+ });
68
+ }
69
+ }
70
+ }
71
+ const created = synced.filter((r) => r.action === "created").length;
72
+ const updated = synced.filter((r) => r.action === "updated").length;
73
+ const skipped = synced.filter((r) => r.action === "skipped").length;
74
+ return {
75
+ success: failed.length === 0,
76
+ synced,
77
+ failed,
78
+ externalRefs,
79
+ summary: {
80
+ total: userStories.length,
81
+ created,
82
+ updated,
83
+ skipped,
84
+ failed: failed.length
85
+ }
86
+ };
87
+ }
88
+ /**
89
+ * Sync a single user story to ADO
90
+ *
91
+ * For 2-level structures:
92
+ * - **Project**: maps to ADO project
93
+ * - **Board**: maps to area path under the project
94
+ */
95
+ async syncUserStory(story, mapping, featureId, options) {
96
+ const title = `[${featureId}][${story.id}] ${story.title}`;
97
+ const description = this.buildWorkItemDescription(story, featureId);
98
+ let areaPath = mapping.areaPath || mapping.project;
99
+ if (story.board) {
100
+ areaPath = `${mapping.project}\\${story.board}`;
101
+ }
102
+ if (options.dryRun) {
103
+ this.logger.log(` \u{1F50D} [DRY-RUN] Would sync ${story.id} to ${mapping.project} (area: ${areaPath})`);
104
+ return {
105
+ usId: story.id,
106
+ projectId: story.project || "unknown",
107
+ adoProject: mapping.project,
108
+ areaPath,
109
+ workItemId: 0,
110
+ url: "",
111
+ action: "skipped"
112
+ };
113
+ }
114
+ const existingItem = await this.findExistingWorkItem(mapping.project, story.id);
115
+ if (existingItem) {
116
+ await this.adoClient.updateWorkItem(
117
+ mapping.project,
118
+ existingItem.id,
119
+ title,
120
+ description,
121
+ areaPath
122
+ );
123
+ this.logger.log(` \u{1F504} Updated ${story.id} \u2192 ${mapping.project}/${existingItem.id}`);
124
+ return {
125
+ usId: story.id,
126
+ projectId: story.project || "unknown",
127
+ adoProject: mapping.project,
128
+ areaPath,
129
+ workItemId: existingItem.id,
130
+ url: this.adoClient.getWorkItemUrl(mapping.project, existingItem.id),
131
+ action: "updated"
132
+ };
133
+ } else {
134
+ const newItem = await this.adoClient.createWorkItem(
135
+ mapping.project,
136
+ "User Story",
137
+ title,
138
+ description,
139
+ areaPath
140
+ );
141
+ this.logger.log(` \u2705 Created ${story.id} \u2192 ${mapping.project}/${newItem.id}`);
142
+ return {
143
+ usId: story.id,
144
+ projectId: story.project || "unknown",
145
+ adoProject: mapping.project,
146
+ areaPath,
147
+ workItemId: newItem.id,
148
+ url: newItem.url,
149
+ action: "created"
150
+ };
151
+ }
152
+ }
153
+ /**
154
+ * Find existing work item by US ID in title
155
+ */
156
+ async findExistingWorkItem(project, usId) {
157
+ try {
158
+ const query = `[System.Title] Contains '[${usId}]'`;
159
+ const results = await this.adoClient.searchWorkItems(project, query);
160
+ return results.length > 0 ? { id: results[0].id } : null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+ /**
166
+ * Build work item description from user story
167
+ */
168
+ buildWorkItemDescription(story, featureId) {
169
+ const lines = [];
170
+ lines.push(`<h1>${story.title}</h1>`);
171
+ lines.push("");
172
+ if (story.description) {
173
+ lines.push(`<p>${story.description}</p>`);
174
+ lines.push("");
175
+ }
176
+ if (story.acceptanceCriteria && story.acceptanceCriteria.length > 0) {
177
+ lines.push("<h2>Acceptance Criteria</h2>");
178
+ lines.push("<ul>");
179
+ for (const ac of story.acceptanceCriteria) {
180
+ lines.push(` <li>${ac}</li>`);
181
+ }
182
+ lines.push("</ul>");
183
+ lines.push("");
184
+ }
185
+ lines.push("<hr/>");
186
+ lines.push("");
187
+ lines.push(`<p><strong>Feature</strong>: ${featureId}</p>`);
188
+ lines.push(`<p><strong>User Story</strong>: ${story.id}</p>`);
189
+ if (story.project) {
190
+ lines.push(`<p><strong>Project</strong>: ${story.project}</p>`);
191
+ }
192
+ if (story.board) {
193
+ lines.push(`<p><strong>Board</strong>: ${story.board}</p>`);
194
+ }
195
+ lines.push("");
196
+ lines.push("<p><em>Auto-generated by SpecWeave</em></p>");
197
+ return lines.join("\n");
198
+ }
199
+ /**
200
+ * Group user stories by their explicit project field
201
+ */
202
+ groupByProject(userStories, defaultProject) {
203
+ const groups = /* @__PURE__ */ new Map();
204
+ for (const story of userStories) {
205
+ const project = story.project || defaultProject || "default";
206
+ if (!groups.has(project)) {
207
+ groups.set(project, []);
208
+ }
209
+ groups.get(project).push(story);
210
+ }
211
+ return groups;
212
+ }
213
+ }
214
+ function formatPerUSSyncResults(result) {
215
+ const lines = [];
216
+ lines.push("");
217
+ lines.push("\u{1F4CA} Per-US ADO Sync Results");
218
+ lines.push("");
219
+ const byProject = /* @__PURE__ */ new Map();
220
+ for (const r of [...result.synced, ...result.failed]) {
221
+ const existing = byProject.get(r.projectId) || [];
222
+ existing.push(r);
223
+ byProject.set(r.projectId, existing);
224
+ }
225
+ for (const [projectId, results] of byProject) {
226
+ const adoProject = results[0]?.adoProject || "N/A";
227
+ const areaPath = results[0]?.areaPath || "";
228
+ lines.push(`**${projectId}** (\u2192 ${adoProject}${areaPath ? ` [${areaPath}]` : ""}):`);
229
+ for (const r of results) {
230
+ const icon = r.action === "created" ? "\u2705" : r.action === "updated" ? "\u{1F504}" : r.error ? "\u274C" : "\u23ED\uFE0F";
231
+ if (r.workItemId > 0) {
232
+ lines.push(` ${icon} ${r.usId} \u2192 ${r.adoProject}/${r.workItemId}`);
233
+ } else if (r.error) {
234
+ lines.push(` ${icon} ${r.usId}: ${r.error}`);
235
+ } else {
236
+ lines.push(` ${icon} ${r.usId} (${r.action})`);
237
+ }
238
+ }
239
+ lines.push("");
240
+ }
241
+ lines.push(`\u{1F4C8} Summary: ${result.summary.created} created, ${result.summary.updated} updated, ${result.summary.failed} failed`);
242
+ return lines.join("\n");
243
+ }
244
+ export {
245
+ PerUSAdoSync,
246
+ formatPerUSSyncResults
247
+ };
@@ -0,0 +1,410 @@
1
+ /**
2
+ * Per-US Azure DevOps Sync (v0.34.0+)
3
+ *
4
+ * Syncs each User Story to its explicitly declared project's ADO project/area.
5
+ * Uses the **Project**: and **Board**: fields in spec.md (NOT keyword-based classification).
6
+ *
7
+ * Key difference from multi-project-sync:
8
+ * - Multi-project sync uses keyword/heuristic classification
9
+ * - Per-US sync uses EXPLICIT **Project**: and **Board**: fields from spec.md
10
+ *
11
+ * @module per-us-sync
12
+ * @since v0.34.0
13
+ */
14
+
15
+ import type { UserStoryData } from '../../../src/core/living-docs/types.js';
16
+ import type { ProjectMappings, AdoMapping } from '../../../src/core/types/config.js';
17
+ import type { USExternalRefsMap } from '../../../src/core/types/increment-metadata.js';
18
+ import { Logger, consoleLogger } from '../../../src/utils/logger.js';
19
+
20
+ /**
21
+ * Result of syncing a single US to ADO
22
+ */
23
+ export interface USSyncResult {
24
+ usId: string;
25
+ projectId: string;
26
+ adoProject: string;
27
+ areaPath: string;
28
+ workItemId: number;
29
+ url: string;
30
+ action: 'created' | 'updated' | 'skipped';
31
+ error?: string;
32
+ }
33
+
34
+ /**
35
+ * Result of syncing all USs in an increment
36
+ */
37
+ export interface PerUSSyncResult {
38
+ success: boolean;
39
+ synced: USSyncResult[];
40
+ failed: USSyncResult[];
41
+ externalRefs: USExternalRefsMap;
42
+ summary: {
43
+ total: number;
44
+ created: number;
45
+ updated: number;
46
+ skipped: number;
47
+ failed: number;
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Options for per-US sync
53
+ */
54
+ export interface PerUSSyncOptions {
55
+ dryRun?: boolean;
56
+ force?: boolean;
57
+ defaultProject?: string;
58
+ defaultBoard?: string;
59
+ logger?: Logger;
60
+ }
61
+
62
+ /**
63
+ * ADO client interface (to be injected)
64
+ */
65
+ export interface AdoClient {
66
+ createWorkItem(
67
+ project: string,
68
+ workItemType: string,
69
+ title: string,
70
+ description: string,
71
+ areaPath?: string
72
+ ): Promise<{ id: number; url: string }>;
73
+ updateWorkItem(
74
+ project: string,
75
+ workItemId: number,
76
+ title: string,
77
+ description: string,
78
+ areaPath?: string
79
+ ): Promise<void>;
80
+ searchWorkItems(project: string, query: string): Promise<Array<{ id: number; fields: { 'System.Title': string } }>>;
81
+ getWorkItemUrl(project: string, workItemId: number): string;
82
+ }
83
+
84
+ /**
85
+ * Per-US ADO Sync
86
+ *
87
+ * Syncs each US to its declared project's ADO project/area path.
88
+ * For 2-level structures, uses **Board**: to determine area path.
89
+ */
90
+ export class PerUSAdoSync {
91
+ private projectMappings: ProjectMappings;
92
+ private adoClient: AdoClient;
93
+ private logger: Logger;
94
+
95
+ constructor(
96
+ adoClient: AdoClient,
97
+ projectMappings: ProjectMappings,
98
+ options: { logger?: Logger } = {}
99
+ ) {
100
+ this.adoClient = adoClient;
101
+ this.projectMappings = projectMappings;
102
+ this.logger = options.logger ?? consoleLogger;
103
+ }
104
+
105
+ /**
106
+ * Sync all user stories to their respective ADO projects
107
+ *
108
+ * @param userStories - User stories with explicit project/board fields
109
+ * @param featureId - Feature ID (e.g., "FS-137")
110
+ * @param options - Sync options
111
+ */
112
+ async syncUserStories(
113
+ userStories: UserStoryData[],
114
+ featureId: string,
115
+ options: PerUSSyncOptions = {}
116
+ ): Promise<PerUSSyncResult> {
117
+ const synced: USSyncResult[] = [];
118
+ const failed: USSyncResult[] = [];
119
+ const externalRefs: USExternalRefsMap = {};
120
+
121
+ // Group USs by their declared project
122
+ const groups = this.groupByProject(userStories, options.defaultProject);
123
+
124
+ this.logger.log(`📡 Per-US ADO Sync: ${userStories.length} USs across ${groups.size} projects`);
125
+
126
+ for (const [projectId, stories] of groups) {
127
+ // Get ADO mapping for this project
128
+ const mapping = this.projectMappings[projectId]?.ado;
129
+
130
+ if (!mapping) {
131
+ // No ADO mapping for this project
132
+ this.logger.warn(` ⚠️ No ADO mapping for project "${projectId}" - skipping ${stories.length} USs`);
133
+ for (const story of stories) {
134
+ failed.push({
135
+ usId: story.id,
136
+ projectId,
137
+ adoProject: 'N/A',
138
+ areaPath: '',
139
+ workItemId: 0,
140
+ url: '',
141
+ action: 'skipped',
142
+ error: `No ADO mapping for project "${projectId}"`
143
+ });
144
+ }
145
+ continue;
146
+ }
147
+
148
+ // Sync each US to this project's ADO project
149
+ for (const story of stories) {
150
+ try {
151
+ const result = await this.syncUserStory(story, mapping, featureId, options);
152
+ synced.push({
153
+ ...result,
154
+ projectId
155
+ });
156
+
157
+ // Build external ref
158
+ if (!options.dryRun && result.action !== 'skipped') {
159
+ externalRefs[story.id] = {
160
+ ado: {
161
+ provider: 'ado',
162
+ issueNumber: result.workItemId,
163
+ url: result.url,
164
+ targetProject: projectId,
165
+ lastSynced: new Date().toISOString()
166
+ }
167
+ };
168
+ }
169
+ } catch (error) {
170
+ failed.push({
171
+ usId: story.id,
172
+ projectId,
173
+ adoProject: mapping.project,
174
+ areaPath: mapping.areaPath || '',
175
+ workItemId: 0,
176
+ url: '',
177
+ action: 'skipped',
178
+ error: error instanceof Error ? error.message : String(error)
179
+ });
180
+ }
181
+ }
182
+ }
183
+
184
+ // Calculate summary
185
+ const created = synced.filter(r => r.action === 'created').length;
186
+ const updated = synced.filter(r => r.action === 'updated').length;
187
+ const skipped = synced.filter(r => r.action === 'skipped').length;
188
+
189
+ return {
190
+ success: failed.length === 0,
191
+ synced,
192
+ failed,
193
+ externalRefs,
194
+ summary: {
195
+ total: userStories.length,
196
+ created,
197
+ updated,
198
+ skipped,
199
+ failed: failed.length
200
+ }
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Sync a single user story to ADO
206
+ *
207
+ * For 2-level structures:
208
+ * - **Project**: maps to ADO project
209
+ * - **Board**: maps to area path under the project
210
+ */
211
+ private async syncUserStory(
212
+ story: UserStoryData,
213
+ mapping: AdoMapping,
214
+ featureId: string,
215
+ options: PerUSSyncOptions
216
+ ): Promise<USSyncResult> {
217
+ const title = `[${featureId}][${story.id}] ${story.title}`;
218
+ const description = this.buildWorkItemDescription(story, featureId);
219
+
220
+ // Determine area path:
221
+ // - Use story.board if available (2-level structure)
222
+ // - Fall back to mapping.areaPath
223
+ // - Fall back to project root
224
+ let areaPath = mapping.areaPath || mapping.project;
225
+ if (story.board) {
226
+ // 2-level structure: append board as area path segment
227
+ areaPath = `${mapping.project}\\${story.board}`;
228
+ }
229
+
230
+ if (options.dryRun) {
231
+ this.logger.log(` 🔍 [DRY-RUN] Would sync ${story.id} to ${mapping.project} (area: ${areaPath})`);
232
+ return {
233
+ usId: story.id,
234
+ projectId: story.project || 'unknown',
235
+ adoProject: mapping.project,
236
+ areaPath,
237
+ workItemId: 0,
238
+ url: '',
239
+ action: 'skipped'
240
+ };
241
+ }
242
+
243
+ // Check for existing work item
244
+ const existingItem = await this.findExistingWorkItem(mapping.project, story.id);
245
+
246
+ if (existingItem) {
247
+ // Update existing work item
248
+ await this.adoClient.updateWorkItem(
249
+ mapping.project,
250
+ existingItem.id,
251
+ title,
252
+ description,
253
+ areaPath
254
+ );
255
+
256
+ this.logger.log(` 🔄 Updated ${story.id} → ${mapping.project}/${existingItem.id}`);
257
+
258
+ return {
259
+ usId: story.id,
260
+ projectId: story.project || 'unknown',
261
+ adoProject: mapping.project,
262
+ areaPath,
263
+ workItemId: existingItem.id,
264
+ url: this.adoClient.getWorkItemUrl(mapping.project, existingItem.id),
265
+ action: 'updated'
266
+ };
267
+ } else {
268
+ // Create new work item
269
+ const newItem = await this.adoClient.createWorkItem(
270
+ mapping.project,
271
+ 'User Story',
272
+ title,
273
+ description,
274
+ areaPath
275
+ );
276
+
277
+ this.logger.log(` ✅ Created ${story.id} → ${mapping.project}/${newItem.id}`);
278
+
279
+ return {
280
+ usId: story.id,
281
+ projectId: story.project || 'unknown',
282
+ adoProject: mapping.project,
283
+ areaPath,
284
+ workItemId: newItem.id,
285
+ url: newItem.url,
286
+ action: 'created'
287
+ };
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Find existing work item by US ID in title
293
+ */
294
+ private async findExistingWorkItem(
295
+ project: string,
296
+ usId: string
297
+ ): Promise<{ id: number } | null> {
298
+ try {
299
+ const query = `[System.Title] Contains '[${usId}]'`;
300
+ const results = await this.adoClient.searchWorkItems(project, query);
301
+
302
+ return results.length > 0 ? { id: results[0].id } : null;
303
+ } catch {
304
+ return null;
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Build work item description from user story
310
+ */
311
+ private buildWorkItemDescription(story: UserStoryData, featureId: string): string {
312
+ const lines: string[] = [];
313
+
314
+ lines.push(`<h1>${story.title}</h1>`);
315
+ lines.push('');
316
+
317
+ if (story.description) {
318
+ lines.push(`<p>${story.description}</p>`);
319
+ lines.push('');
320
+ }
321
+
322
+ if (story.acceptanceCriteria && story.acceptanceCriteria.length > 0) {
323
+ lines.push('<h2>Acceptance Criteria</h2>');
324
+ lines.push('<ul>');
325
+ for (const ac of story.acceptanceCriteria) {
326
+ lines.push(` <li>${ac}</li>`);
327
+ }
328
+ lines.push('</ul>');
329
+ lines.push('');
330
+ }
331
+
332
+ lines.push('<hr/>');
333
+ lines.push('');
334
+ lines.push(`<p><strong>Feature</strong>: ${featureId}</p>`);
335
+ lines.push(`<p><strong>User Story</strong>: ${story.id}</p>`);
336
+ if (story.project) {
337
+ lines.push(`<p><strong>Project</strong>: ${story.project}</p>`);
338
+ }
339
+ if (story.board) {
340
+ lines.push(`<p><strong>Board</strong>: ${story.board}</p>`);
341
+ }
342
+ lines.push('');
343
+ lines.push('<p><em>Auto-generated by SpecWeave</em></p>');
344
+
345
+ return lines.join('\n');
346
+ }
347
+
348
+ /**
349
+ * Group user stories by their explicit project field
350
+ */
351
+ private groupByProject(
352
+ userStories: UserStoryData[],
353
+ defaultProject?: string
354
+ ): Map<string, UserStoryData[]> {
355
+ const groups = new Map<string, UserStoryData[]>();
356
+
357
+ for (const story of userStories) {
358
+ const project = story.project || defaultProject || 'default';
359
+
360
+ if (!groups.has(project)) {
361
+ groups.set(project, []);
362
+ }
363
+ groups.get(project)!.push(story);
364
+ }
365
+
366
+ return groups;
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Format per-US sync results for display
372
+ */
373
+ export function formatPerUSSyncResults(result: PerUSSyncResult): string {
374
+ const lines: string[] = [];
375
+
376
+ lines.push('');
377
+ lines.push('📊 Per-US ADO Sync Results');
378
+ lines.push('');
379
+
380
+ // Group by project
381
+ const byProject = new Map<string, USSyncResult[]>();
382
+ for (const r of [...result.synced, ...result.failed]) {
383
+ const existing = byProject.get(r.projectId) || [];
384
+ existing.push(r);
385
+ byProject.set(r.projectId, existing);
386
+ }
387
+
388
+ for (const [projectId, results] of byProject) {
389
+ const adoProject = results[0]?.adoProject || 'N/A';
390
+ const areaPath = results[0]?.areaPath || '';
391
+ lines.push(`**${projectId}** (→ ${adoProject}${areaPath ? ` [${areaPath}]` : ''}):`);
392
+ for (const r of results) {
393
+ const icon = r.action === 'created' ? '✅' :
394
+ r.action === 'updated' ? '🔄' :
395
+ r.error ? '❌' : '⏭️';
396
+ if (r.workItemId > 0) {
397
+ lines.push(` ${icon} ${r.usId} → ${r.adoProject}/${r.workItemId}`);
398
+ } else if (r.error) {
399
+ lines.push(` ${icon} ${r.usId}: ${r.error}`);
400
+ } else {
401
+ lines.push(` ${icon} ${r.usId} (${r.action})`);
402
+ }
403
+ }
404
+ lines.push('');
405
+ }
406
+
407
+ lines.push(`📈 Summary: ${result.summary.created} created, ${result.summary.updated} updated, ${result.summary.failed} failed`);
408
+
409
+ return lines.join('\n');
410
+ }
@@ -291,10 +291,13 @@ ${body}`;
291
291
  * Search for issue by exact title match
292
292
  *
293
293
  * IDEMPOTENCY: Use this before creating issues to prevent duplicates
294
+ *
295
+ * @param title - Title pattern to search for (e.g., "[FS-136][US-001]")
296
+ * @param includeClosedIssues - If true, searches all issues (open+closed). Default: false (open only)
294
297
  */
295
- async searchIssueByTitle(title) {
298
+ async searchIssueByTitle(title, includeClosedIssues = false) {
296
299
  const escapedTitle = title.replace(/"/g, '\\"');
297
- const result = await execFileNoThrow("gh", [
300
+ const args = [
298
301
  "issue",
299
302
  "list",
300
303
  "--repo",
@@ -306,7 +309,11 @@ ${body}`;
306
309
  "--limit",
307
310
  "50"
308
311
  // ✅ FIX: Increased from 1 to 50 to catch duplicates (Issue #0047)
309
- ]);
312
+ ];
313
+ if (includeClosedIssues) {
314
+ args.push("--state", "all");
315
+ }
316
+ const result = await execFileNoThrow("gh", args);
310
317
  if (result.exitCode !== 0) {
311
318
  return null;
312
319
  }