specweave 1.0.488 → 1.0.490

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 (151) hide show
  1. package/README.md +1 -1
  2. package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.d.ts +13 -5
  3. package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.d.ts.map +1 -1
  4. package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js +28 -26
  5. package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js.map +1 -1
  6. package/dist/plugins/specweave-ado/lib/ado-client.d.ts +14 -0
  7. package/dist/plugins/specweave-ado/lib/ado-client.d.ts.map +1 -1
  8. package/dist/plugins/specweave-ado/lib/ado-client.js +29 -1
  9. package/dist/plugins/specweave-ado/lib/ado-client.js.map +1 -1
  10. package/dist/plugins/specweave-ado/lib/ado-pull-sync.d.ts +35 -0
  11. package/dist/plugins/specweave-ado/lib/ado-pull-sync.d.ts.map +1 -0
  12. package/dist/plugins/specweave-ado/lib/ado-pull-sync.js +53 -0
  13. package/dist/plugins/specweave-ado/lib/ado-pull-sync.js.map +1 -0
  14. package/dist/plugins/specweave-ado/lib/ado-rate-limiter.d.ts +46 -0
  15. package/dist/plugins/specweave-ado/lib/ado-rate-limiter.d.ts.map +1 -0
  16. package/dist/plugins/specweave-ado/lib/ado-rate-limiter.js +65 -0
  17. package/dist/plugins/specweave-ado/lib/ado-rate-limiter.js.map +1 -0
  18. package/dist/plugins/specweave-ado/lib/ado-spec-sync.d.ts +7 -1
  19. package/dist/plugins/specweave-ado/lib/ado-spec-sync.d.ts.map +1 -1
  20. package/dist/plugins/specweave-ado/lib/ado-spec-sync.js +25 -1
  21. package/dist/plugins/specweave-ado/lib/ado-spec-sync.js.map +1 -1
  22. package/dist/plugins/specweave-ado/lib/ado-status-sync.d.ts +17 -1
  23. package/dist/plugins/specweave-ado/lib/ado-status-sync.d.ts.map +1 -1
  24. package/dist/plugins/specweave-ado/lib/ado-status-sync.js +51 -9
  25. package/dist/plugins/specweave-ado/lib/ado-status-sync.js.map +1 -1
  26. package/dist/plugins/specweave-github/lib/github-client-v2.js +1 -1
  27. package/dist/plugins/specweave-github/lib/github-client-v2.js.map +1 -1
  28. package/dist/plugins/specweave-github/lib/github-push-sync.d.ts.map +1 -1
  29. package/dist/plugins/specweave-github/lib/github-push-sync.js +15 -3
  30. package/dist/plugins/specweave-github/lib/github-push-sync.js.map +1 -1
  31. package/dist/plugins/specweave-jira/lib/jira-spec-sync.d.ts +31 -1
  32. package/dist/plugins/specweave-jira/lib/jira-spec-sync.d.ts.map +1 -1
  33. package/dist/plugins/specweave-jira/lib/jira-spec-sync.js +170 -97
  34. package/dist/plugins/specweave-jira/lib/jira-spec-sync.js.map +1 -1
  35. package/dist/plugins/specweave-jira/lib/jira-status-sync.d.ts +36 -1
  36. package/dist/plugins/specweave-jira/lib/jira-status-sync.d.ts.map +1 -1
  37. package/dist/plugins/specweave-jira/lib/jira-status-sync.js +185 -82
  38. package/dist/plugins/specweave-jira/lib/jira-status-sync.js.map +1 -1
  39. package/dist/src/adapters/adapter-loader.d.ts.map +1 -1
  40. package/dist/src/adapters/adapter-loader.js +8 -2
  41. package/dist/src/adapters/adapter-loader.js.map +1 -1
  42. package/dist/src/adapters/codex/adapter.d.ts.map +1 -1
  43. package/dist/src/adapters/codex/adapter.js +1 -0
  44. package/dist/src/adapters/codex/adapter.js.map +1 -1
  45. package/dist/src/adapters/cursor/adapter.d.ts.map +1 -1
  46. package/dist/src/adapters/cursor/adapter.js +1 -0
  47. package/dist/src/adapters/cursor/adapter.js.map +1 -1
  48. package/dist/src/adapters/generic/adapter.d.ts +6 -3
  49. package/dist/src/adapters/generic/adapter.d.ts.map +1 -1
  50. package/dist/src/adapters/generic/adapter.js +53 -47
  51. package/dist/src/adapters/generic/adapter.js.map +1 -1
  52. package/dist/src/adapters/kimi/adapter.d.ts +21 -0
  53. package/dist/src/adapters/kimi/adapter.d.ts.map +1 -0
  54. package/dist/src/adapters/kimi/adapter.js +57 -0
  55. package/dist/src/adapters/kimi/adapter.js.map +1 -0
  56. package/dist/src/adapters/opencode/adapter.d.ts +24 -0
  57. package/dist/src/adapters/opencode/adapter.d.ts.map +1 -0
  58. package/dist/src/adapters/opencode/adapter.js +71 -0
  59. package/dist/src/adapters/opencode/adapter.js.map +1 -0
  60. package/dist/src/adapters/registry.yaml +59 -0
  61. package/dist/src/adapters/trae/adapter.d.ts +21 -0
  62. package/dist/src/adapters/trae/adapter.d.ts.map +1 -0
  63. package/dist/src/adapters/trae/adapter.js +64 -0
  64. package/dist/src/adapters/trae/adapter.js.map +1 -0
  65. package/dist/src/cli/commands/init.d.ts.map +1 -1
  66. package/dist/src/cli/commands/init.js +156 -5
  67. package/dist/src/cli/commands/init.js.map +1 -1
  68. package/dist/src/cli/commands/update-instructions.d.ts.map +1 -1
  69. package/dist/src/cli/commands/update-instructions.js +10 -0
  70. package/dist/src/cli/commands/update-instructions.js.map +1 -1
  71. package/dist/src/cli/helpers/init/index.d.ts +1 -0
  72. package/dist/src/cli/helpers/init/index.d.ts.map +1 -1
  73. package/dist/src/cli/helpers/init/index.js +2 -0
  74. package/dist/src/cli/helpers/init/index.js.map +1 -1
  75. package/dist/src/cli/helpers/init/next-steps.d.ts.map +1 -1
  76. package/dist/src/cli/helpers/init/next-steps.js +52 -0
  77. package/dist/src/cli/helpers/init/next-steps.js.map +1 -1
  78. package/dist/src/cli/helpers/init/skill-creator-installer.d.ts +24 -0
  79. package/dist/src/cli/helpers/init/skill-creator-installer.d.ts.map +1 -0
  80. package/dist/src/cli/helpers/init/skill-creator-installer.js +54 -0
  81. package/dist/src/cli/helpers/init/skill-creator-installer.js.map +1 -0
  82. package/dist/src/core/ado-description-updater.d.ts +22 -0
  83. package/dist/src/core/ado-description-updater.d.ts.map +1 -0
  84. package/dist/src/core/ado-description-updater.js +46 -0
  85. package/dist/src/core/ado-description-updater.js.map +1 -0
  86. package/dist/src/core/closure-dispatcher.d.ts +96 -0
  87. package/dist/src/core/closure-dispatcher.d.ts.map +1 -0
  88. package/dist/src/core/closure-dispatcher.js +116 -0
  89. package/dist/src/core/closure-dispatcher.js.map +1 -0
  90. package/dist/src/core/config/types.d.ts +2 -0
  91. package/dist/src/core/config/types.d.ts.map +1 -1
  92. package/dist/src/core/config/types.js.map +1 -1
  93. package/dist/src/core/errors/sync-error.d.ts +12 -0
  94. package/dist/src/core/errors/sync-error.d.ts.map +1 -0
  95. package/dist/src/core/errors/sync-error.js +19 -0
  96. package/dist/src/core/errors/sync-error.js.map +1 -0
  97. package/dist/src/core/skill-gen/rule-collector.d.ts +28 -0
  98. package/dist/src/core/skill-gen/rule-collector.d.ts.map +1 -0
  99. package/dist/src/core/skill-gen/rule-collector.js +112 -0
  100. package/dist/src/core/skill-gen/rule-collector.js.map +1 -0
  101. package/dist/src/core/skill-gen/signal-collector.d.ts +2 -1
  102. package/dist/src/core/skill-gen/signal-collector.d.ts.map +1 -1
  103. package/dist/src/core/skill-gen/signal-collector.js +21 -5
  104. package/dist/src/core/skill-gen/signal-collector.js.map +1 -1
  105. package/dist/src/core/sync/persistent-circuit-breaker.d.ts +22 -0
  106. package/dist/src/core/sync/persistent-circuit-breaker.d.ts.map +1 -0
  107. package/dist/src/core/sync/persistent-circuit-breaker.js +65 -0
  108. package/dist/src/core/sync/persistent-circuit-breaker.js.map +1 -0
  109. package/dist/src/core/sync/retry-wrapper.d.ts +13 -0
  110. package/dist/src/core/sync/retry-wrapper.d.ts.map +1 -0
  111. package/dist/src/core/sync/retry-wrapper.js +37 -0
  112. package/dist/src/core/sync/retry-wrapper.js.map +1 -0
  113. package/dist/src/importers/ac-parser.d.ts +27 -0
  114. package/dist/src/importers/ac-parser.d.ts.map +1 -0
  115. package/dist/src/importers/ac-parser.js +47 -0
  116. package/dist/src/importers/ac-parser.js.map +1 -0
  117. package/dist/src/sync/types.d.ts +8 -0
  118. package/dist/src/sync/types.d.ts.map +1 -1
  119. package/dist/src/sync/types.js +12 -0
  120. package/dist/src/sync/types.js.map +1 -1
  121. package/package.json +1 -1
  122. package/plugins/specweave/PLUGIN.md +1 -0
  123. package/plugins/specweave/hooks/v2/guards/increment-existence-guard.sh +9 -3
  124. package/plugins/specweave/skills/code-reviewer/SKILL.md +401 -0
  125. package/plugins/specweave/skills/code-reviewer/agents/reviewer-silent-failures.md +65 -0
  126. package/plugins/specweave/skills/code-reviewer/agents/reviewer-spec-compliance.md +83 -0
  127. package/plugins/specweave/skills/code-reviewer/agents/reviewer-types.md +68 -0
  128. package/plugins/specweave/skills/skill-gen/SKILL.md +20 -3
  129. package/plugins/specweave/skills/team-lead/SKILL.md +155 -4
  130. package/plugins/specweave/skills/team-lead/agents/architect.md +52 -0
  131. package/plugins/specweave/skills/team-lead/agents/pm.md +50 -0
  132. package/plugins/specweave/skills/team-lead/agents/researcher.md +64 -0
  133. package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js +23 -21
  134. package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.ts +37 -29
  135. package/plugins/specweave-ado/lib/ado-client.js +27 -1
  136. package/plugins/specweave-ado/lib/ado-client.ts +37 -2
  137. package/plugins/specweave-ado/lib/ado-pull-sync.js +35 -0
  138. package/plugins/specweave-ado/lib/ado-pull-sync.ts +74 -0
  139. package/plugins/specweave-ado/lib/ado-rate-limiter.js +56 -0
  140. package/plugins/specweave-ado/lib/ado-rate-limiter.ts +86 -0
  141. package/plugins/specweave-ado/lib/ado-spec-sync.js +25 -1
  142. package/plugins/specweave-ado/lib/ado-spec-sync.ts +32 -2
  143. package/plugins/specweave-ado/lib/ado-status-sync.js +52 -14
  144. package/plugins/specweave-ado/lib/ado-status-sync.ts +64 -16
  145. package/plugins/specweave-github/lib/github-client-v2.ts +1 -1
  146. package/plugins/specweave-github/lib/github-push-sync.js +11 -3
  147. package/plugins/specweave-github/lib/github-push-sync.ts +16 -3
  148. package/plugins/specweave-jira/lib/jira-spec-sync.js +60 -1
  149. package/plugins/specweave-jira/lib/jira-spec-sync.ts +93 -1
  150. package/plugins/specweave-jira/lib/jira-status-sync.js +151 -109
  151. package/plugins/specweave-jira/lib/jira-status-sync.ts +161 -39
@@ -11,7 +11,10 @@
11
11
  * @module ado-status-sync
12
12
  */
13
13
 
14
- import axios, { AxiosInstance } from 'axios';
14
+ import axios, { AxiosInstance, AxiosError } from 'axios';
15
+ import { SyncCircuitBreaker } from '../../../src/core/increment/sync-circuit-breaker.js';
16
+ import { withRetry } from '../../../src/core/sync/retry-wrapper.js';
17
+ import { SyncError } from '../../../src/core/errors/sync-error.js';
15
18
 
16
19
  /**
17
20
  * External status representation (ADO-specific)
@@ -25,19 +28,24 @@ export interface ExternalStatus {
25
28
  * Azure DevOps Status Sync
26
29
  *
27
30
  * Handles status synchronization with ADO work items.
31
+ * Integrates circuit breaker (opens after 3 consecutive failures)
32
+ * and retry with exponential backoff for transient errors.
28
33
  */
29
34
  export class AdoStatusSync {
30
35
  private client: AxiosInstance;
31
36
  private organization: string;
32
37
  private project: string;
38
+ private breaker: SyncCircuitBreaker;
33
39
 
34
40
  constructor(
35
41
  organization: string,
36
42
  project: string,
37
- personalAccessToken: string
43
+ personalAccessToken: string,
44
+ breaker?: SyncCircuitBreaker
38
45
  ) {
39
46
  this.organization = organization;
40
47
  this.project = project;
48
+ this.breaker = breaker ?? new SyncCircuitBreaker();
41
49
 
42
50
  // Create ADO API client
43
51
  this.client = axios.create({
@@ -60,13 +68,13 @@ export class AdoStatusSync {
60
68
  * @returns Current work item state
61
69
  */
62
70
  async getStatus(workItemId: number): Promise<ExternalStatus> {
63
- const response = await this.client.get(
64
- `/wit/workitems/${workItemId}?api-version=7.0`
65
- );
71
+ this.assertCircuitClosed();
66
72
 
67
- return {
68
- state: response.data.fields['System.State']
69
- };
73
+ return this.withResilienceWrapper(() =>
74
+ this.client.get(`/wit/workitems/${workItemId}?api-version=7.0`).then(response => ({
75
+ state: response.data.fields['System.State'] as string,
76
+ }))
77
+ );
70
78
  }
71
79
 
72
80
  /**
@@ -79,6 +87,7 @@ export class AdoStatusSync {
79
87
  * @param status - Desired status with state and optional tags
80
88
  */
81
89
  async updateStatus(workItemId: number, status: ExternalStatus): Promise<void> {
90
+ this.assertCircuitClosed();
82
91
  // ADO uses JSON Patch format for updates
83
92
  const patch: Array<{ op: string; path: string; value: string }> = [
84
93
  {
@@ -109,12 +118,18 @@ export class AdoStatusSync {
109
118
  });
110
119
  }
111
120
 
112
- await this.client.patch(
113
- `/wit/workitems/${workItemId}?api-version=7.0`,
114
- patch
121
+ await this.withResilienceWrapper(() =>
122
+ this.client.patch(`/wit/workitems/${workItemId}?api-version=7.0`, patch)
115
123
  );
116
124
  }
117
125
 
126
+ /**
127
+ * Check whether the circuit is closed (sync allowed).
128
+ */
129
+ canSync(): boolean {
130
+ return this.breaker.canSync();
131
+ }
132
+
118
133
  /**
119
134
  * Get current tags from ADO work item
120
135
  *
@@ -148,6 +163,8 @@ export class AdoStatusSync {
148
163
  oldStatus: string,
149
164
  newStatus: string
150
165
  ): Promise<void> {
166
+ this.assertCircuitClosed();
167
+
151
168
  const text = `šŸ”„ Status Update\n\n` +
152
169
  `SpecWeave status changed:\n` +
153
170
  `• From: ${oldStatus}\n` +
@@ -155,11 +172,42 @@ export class AdoStatusSync {
155
172
  `• When: ${new Date().toISOString()}\n\n` +
156
173
  `Synced from SpecWeave`;
157
174
 
158
- await this.client.post(
159
- `/wit/workitems/${workItemId}/comments?api-version=7.0-preview.3`,
160
- {
161
- text
162
- }
175
+ await this.withResilienceWrapper(() =>
176
+ this.client.post(
177
+ `/wit/workitems/${workItemId}/comments?api-version=7.0-preview.3`,
178
+ { text }
179
+ )
163
180
  );
164
181
  }
182
+
183
+ /**
184
+ * Assert the circuit is closed. Throws CircuitOpenError if open.
185
+ */
186
+ private assertCircuitClosed(): void {
187
+ if (!this.breaker.canSync()) {
188
+ throw new SyncError('ado', 503, '', 'Circuit breaker open — sync blocked');
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Wrap an async operation with retry + circuit breaker recording.
194
+ */
195
+ private async withResilienceWrapper<T>(fn: () => Promise<T>): Promise<T> {
196
+ try {
197
+ const result = await withRetry(fn, { maxRetries: 3, baseMs: 500, maxMs: 5000 });
198
+ this.breaker.recordSuccess();
199
+ return result;
200
+ } catch (error: any) {
201
+ this.breaker.recordFailure();
202
+ if (error?.response?.status) {
203
+ const status = error.response.status;
204
+ const body = typeof error.response.data === 'string'
205
+ ? error.response.data
206
+ : JSON.stringify(error.response.data ?? '');
207
+ const detail = error.response.statusText || 'Unknown';
208
+ throw new SyncError('ado', status, body, detail);
209
+ }
210
+ throw error;
211
+ }
212
+ }
165
213
  }
@@ -256,7 +256,7 @@ export class GitHubClientV2 {
256
256
  // Derive proper FS-ID from the increment number
257
257
  const num = parseInt(incrementId.replace('E', ''), 10);
258
258
  const isExternal = incrementId.endsWith('E');
259
- const properFsId = `FS-${String(num).padStart(3, '0')}${isExternal ? 'E' : ''}`;
259
+ const properFsId = `FS-${String(num).padStart(3, '0')}${isExternal ? 'E' : ''}`; // Legacy E suffix — error message only
260
260
  throw new Error(
261
261
  `āŒ INVALID TITLE FORMAT: "${title}"\n\n` +
262
262
  `Plain increment IDs like [${incrementId}] are NOT allowed!\n\n` +
@@ -1,5 +1,10 @@
1
1
  import { execFileNoThrow } from "../../../src/utils/execFileNoThrow.js";
2
2
  import { generateIssueBody } from "./github-issue-body-generator.js";
3
+ import { SyncError } from "../../../src/core/errors/sync-error.js";
4
+ function parseHttpStatus(stderr) {
5
+ const match = stderr.match(/HTTP\s+(\d{3})/);
6
+ return match ? parseInt(match[1], 10) : 0;
7
+ }
3
8
  async function pushSyncUserStories(userStories, options) {
4
9
  const result = { created: [], updated: [], errors: [] };
5
10
  if (options.dryRun) {
@@ -60,7 +65,8 @@ async function searchIssueByPrefix(usId, repoSlug, env) {
60
65
  "1"
61
66
  ], { env });
62
67
  if (!res.success) {
63
- throw new Error(`Search failed: ${res.stderr}`);
68
+ const status = parseHttpStatus(res.stderr);
69
+ throw new SyncError("github", status, res.stderr, `Search failed: ${res.stderr}`);
64
70
  }
65
71
  const issues = JSON.parse(res.stdout);
66
72
  return issues.length > 0 ? issues[0] : null;
@@ -86,7 +92,8 @@ async function createIssue(title, body, us, repoSlug, env) {
86
92
  ];
87
93
  const res = await execFileNoThrow("gh", args, { env });
88
94
  if (!res.success) {
89
- throw new Error(`Create failed: ${res.stderr}`);
95
+ const status = parseHttpStatus(res.stderr);
96
+ throw new SyncError("github", status, res.stderr, `Create failed: ${res.stderr}`);
90
97
  }
91
98
  return JSON.parse(res.stdout);
92
99
  }
@@ -106,7 +113,8 @@ async function updateIssue(issueNumber, title, body, repoSlug, env) {
106
113
  ];
107
114
  const res = await execFileNoThrow("gh", args, { env });
108
115
  if (!res.success) {
109
- throw new Error(`Update failed: ${res.stderr}`);
116
+ const status = parseHttpStatus(res.stderr);
117
+ throw new SyncError("github", status, res.stderr, `Update failed: ${res.stderr}`);
110
118
  }
111
119
  return JSON.parse(res.stdout);
112
120
  }
@@ -9,6 +9,16 @@
9
9
 
10
10
  import { execFileNoThrow } from '../../../src/utils/execFileNoThrow.js';
11
11
  import { generateIssueBody } from './github-issue-body-generator.js';
12
+ import { SyncError } from '../../../src/core/errors/sync-error.js';
13
+
14
+ /**
15
+ * Extract HTTP status code from gh CLI stderr output.
16
+ * gh CLI typically outputs "HTTP 401: Bad credentials" or similar.
17
+ */
18
+ function parseHttpStatus(stderr: string): number {
19
+ const match = stderr.match(/HTTP\s+(\d{3})/);
20
+ return match ? parseInt(match[1], 10) : 0;
21
+ }
12
22
 
13
23
  export interface UserStoryForSync {
14
24
  id: string;
@@ -109,7 +119,8 @@ async function searchIssueByPrefix(
109
119
  ], { env });
110
120
 
111
121
  if (!res.success) {
112
- throw new Error(`Search failed: ${res.stderr}`);
122
+ const status = parseHttpStatus(res.stderr);
123
+ throw new SyncError('github', status, res.stderr, `Search failed: ${res.stderr}`);
113
124
  }
114
125
 
115
126
  const issues = JSON.parse(res.stdout);
@@ -137,7 +148,8 @@ async function createIssue(
137
148
  const res = await execFileNoThrow('gh', args, { env });
138
149
 
139
150
  if (!res.success) {
140
- throw new Error(`Create failed: ${res.stderr}`);
151
+ const status = parseHttpStatus(res.stderr);
152
+ throw new SyncError('github', status, res.stderr, `Create failed: ${res.stderr}`);
141
153
  }
142
154
 
143
155
  return JSON.parse(res.stdout);
@@ -161,7 +173,8 @@ async function updateIssue(
161
173
  const res = await execFileNoThrow('gh', args, { env });
162
174
 
163
175
  if (!res.success) {
164
- throw new Error(`Update failed: ${res.stderr}`);
176
+ const status = parseHttpStatus(res.stderr);
177
+ throw new SyncError('github', status, res.stderr, `Update failed: ${res.stderr}`);
165
178
  }
166
179
 
167
180
  return JSON.parse(res.stdout);
@@ -9,6 +9,10 @@ import { toDescription } from "./content-format-adapter.js";
9
9
  import { getEpicLinkFieldForProject } from "./jira-field-discovery.js";
10
10
  import { searchAllIssues } from "./jira-paginated-search.js";
11
11
  import axios from "axios";
12
+ import { CircuitBreakerRegistry } from "../../../src/core/sync/circuit-breaker-registry.js";
13
+ import { SyncRetryQueue } from "../../../src/core/sync/sync-retry-queue.js";
14
+ import { SyncError } from "../../../src/core/errors/sync-error.js";
15
+ import { LockManager } from "../../../src/utils/lock-manager.js";
12
16
  function buildStoryDescription(us) {
13
17
  const acList = us.acceptanceCriteria.map((ac) => `${ac.status === "done" ? "[x]" : "[ ]"} ${ac.id}: ${ac.description}`).join("\n");
14
18
  return `
@@ -28,10 +32,17 @@ ${acList}
28
32
  `.trim();
29
33
  }
30
34
  class JiraSpecSync {
31
- constructor(config, projectRoot = process.cwd(), projectId) {
35
+ constructor(config, projectRoot = process.cwd(), projectId, options) {
32
36
  this.projectRoot = projectRoot;
33
37
  this.specManager = new SpecMetadataManager(projectRoot, projectId);
34
38
  this.config = config;
39
+ this.circuitBreakerRegistry = options?.circuitBreakerRegistry;
40
+ this.retryQueue = options?.retryQueue;
41
+ this.incrementId = options?.incrementId ?? "";
42
+ this.featureId = options?.featureId ?? "";
43
+ if (options?.lockDir) {
44
+ this.lockManager = new LockManager(options.lockDir);
45
+ }
35
46
  this.client = axios.create({
36
47
  baseURL: getApiBaseUrl(config.domain),
37
48
  auth: {
@@ -44,6 +55,45 @@ class JiraSpecSync {
44
55
  }
45
56
  });
46
57
  }
58
+ async withLock(fn) {
59
+ if (!this.lockManager) return fn();
60
+ const acquired = await this.lockManager.acquire();
61
+ if (!acquired) {
62
+ throw new SyncError("jira", 0, "", "Failed to acquire JIRA sync lock");
63
+ }
64
+ try {
65
+ return await fn();
66
+ } finally {
67
+ await this.lockManager.release();
68
+ }
69
+ }
70
+ checkCircuitBreaker() {
71
+ if (!this.circuitBreakerRegistry) return;
72
+ const breaker = this.circuitBreakerRegistry.get("jira");
73
+ if (!breaker.canSync()) {
74
+ throw new SyncError("jira", 0, "", "Circuit breaker open for jira");
75
+ }
76
+ }
77
+ recordApiSuccess() {
78
+ if (!this.circuitBreakerRegistry) return;
79
+ this.circuitBreakerRegistry.get("jira").recordSuccess();
80
+ }
81
+ async recordApiFailure(error, operation) {
82
+ const httpStatus = error?.response?.status ?? 0;
83
+ if (this.circuitBreakerRegistry) {
84
+ this.circuitBreakerRegistry.get("jira").recordFailure();
85
+ }
86
+ if (this.retryQueue && httpStatus >= 500) {
87
+ await this.retryQueue.enqueue({
88
+ incrementId: this.incrementId,
89
+ provider: "jira",
90
+ featureId: this.featureId,
91
+ projectPath: this.projectRoot,
92
+ projectName: this.config.projectKey,
93
+ error: `${httpStatus} ${operation}: ${error?.message ?? String(error)}`
94
+ });
95
+ }
96
+ }
47
97
  /**
48
98
  * Initialize: detect deployment type and update client baseURL
49
99
  */
@@ -60,6 +110,8 @@ class JiraSpecSync {
60
110
  async syncSpecToJira(specId) {
61
111
  console.log(`
62
112
  \u{1F504} Syncing spec ${specId} to Jira Epic...`);
113
+ return this.withLock(async () => {
114
+ this.checkCircuitBreaker();
63
115
  try {
64
116
  const spec = await this.specManager.loadSpec(specId);
65
117
  if (!spec) {
@@ -86,6 +138,7 @@ class JiraSpecSync {
86
138
  });
87
139
  }
88
140
  const changes = await this.syncUserStories(epic.key, spec);
141
+ this.recordApiSuccess();
89
142
  console.log("\u2705 Sync complete!");
90
143
  return {
91
144
  success: true,
@@ -96,6 +149,7 @@ class JiraSpecSync {
96
149
  changes
97
150
  };
98
151
  } catch (error) {
152
+ await this.recordApiFailure(error, "syncSpecToJira");
99
153
  const axiosData = error?.response?.data;
100
154
  const detail = axiosData ? JSON.stringify(axiosData) : "";
101
155
  console.error("\u274C Error syncing to Jira:", error?.message || error, detail ? `
@@ -107,6 +161,7 @@ class JiraSpecSync {
107
161
  error: error instanceof Error ? error.message : "Unknown error"
108
162
  };
109
163
  }
164
+ });
110
165
  }
111
166
  /**
112
167
  * Sync FROM Jira Epic to spec (bidirectional)
@@ -114,6 +169,8 @@ class JiraSpecSync {
114
169
  async syncFromJira(specId) {
115
170
  console.log(`
116
171
  \u{1F504} Syncing FROM Jira to spec ${specId}...`);
172
+ return this.withLock(async () => {
173
+ this.checkCircuitBreaker();
117
174
  try {
118
175
  const spec = await this.specManager.loadSpec(specId);
119
176
  if (!spec) {
@@ -158,6 +215,7 @@ class JiraSpecSync {
158
215
  conflicts
159
216
  };
160
217
  } catch (error) {
218
+ await this.recordApiFailure(error, "syncFromJira");
161
219
  console.error("\u274C Error syncing FROM Jira:", error);
162
220
  return {
163
221
  success: false,
@@ -166,6 +224,7 @@ class JiraSpecSync {
166
224
  error: error instanceof Error ? error.message : "Unknown error"
167
225
  };
168
226
  }
227
+ });
169
228
  }
170
229
  /**
171
230
  * Create new Jira Epic for spec
@@ -31,6 +31,10 @@ import { toDescription, AdfDocument } from './content-format-adapter.js';
31
31
  import { getEpicLinkFieldForProject } from './jira-field-discovery.js';
32
32
  import { searchAllIssues } from './jira-paginated-search.js';
33
33
  import axios, { AxiosInstance } from 'axios';
34
+ import { CircuitBreakerRegistry } from '../../../src/core/sync/circuit-breaker-registry.js';
35
+ import { SyncRetryQueue } from '../../../src/core/sync/sync-retry-queue.js';
36
+ import { SyncError } from '../../../src/core/errors/sync-error.js';
37
+ import { LockManager } from '../../../src/utils/lock-manager.js';
34
38
 
35
39
  /**
36
40
  * Build a JIRA story description from a UserStory.
@@ -88,16 +92,36 @@ export interface JiraConfig {
88
92
  projectKey: string; // e.g., SPEC
89
93
  }
90
94
 
95
+ export interface JiraSpecSyncOptions {
96
+ circuitBreakerRegistry?: CircuitBreakerRegistry;
97
+ retryQueue?: SyncRetryQueue;
98
+ lockDir?: string;
99
+ incrementId?: string;
100
+ featureId?: string;
101
+ }
102
+
91
103
  export class JiraSpecSync {
92
104
  private specManager: SpecMetadataManager;
93
105
  private client: AxiosInstance;
94
106
  private config: JiraConfig;
95
107
  private projectRoot: string;
108
+ private circuitBreakerRegistry?: CircuitBreakerRegistry;
109
+ private retryQueue?: SyncRetryQueue;
110
+ private lockManager?: LockManager;
111
+ private incrementId: string;
112
+ private featureId: string;
96
113
 
97
- constructor(config: JiraConfig, projectRoot: string = process.cwd(), projectId?: string) {
114
+ constructor(config: JiraConfig, projectRoot: string = process.cwd(), projectId?: string, options?: JiraSpecSyncOptions) {
98
115
  this.projectRoot = projectRoot;
99
116
  this.specManager = new SpecMetadataManager(projectRoot, projectId);
100
117
  this.config = config;
118
+ this.circuitBreakerRegistry = options?.circuitBreakerRegistry;
119
+ this.retryQueue = options?.retryQueue;
120
+ this.incrementId = options?.incrementId ?? '';
121
+ this.featureId = options?.featureId ?? '';
122
+ if (options?.lockDir) {
123
+ this.lockManager = new LockManager(options.lockDir);
124
+ }
101
125
 
102
126
  // Create Jira API client — baseURL set dynamically via init()
103
127
  this.client = axios.create({
@@ -113,6 +137,63 @@ export class JiraSpecSync {
113
137
  });
114
138
  }
115
139
 
140
+ /**
141
+ * Execute fn under file lock (if lockManager configured).
142
+ */
143
+ private async withLock<T>(fn: () => Promise<T>): Promise<T> {
144
+ if (!this.lockManager) return fn();
145
+ const acquired = await this.lockManager.acquire();
146
+ if (!acquired) {
147
+ throw new SyncError('jira', 0, '', 'Failed to acquire JIRA sync lock');
148
+ }
149
+ try {
150
+ return await fn();
151
+ } finally {
152
+ await this.lockManager.release();
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Check circuit breaker before making API calls.
158
+ */
159
+ private checkCircuitBreaker(): void {
160
+ if (!this.circuitBreakerRegistry) return;
161
+ const breaker = this.circuitBreakerRegistry.get('jira');
162
+ if (!breaker.canSync()) {
163
+ throw new SyncError('jira', 0, '', 'Circuit breaker open for jira');
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Record success on circuit breaker.
169
+ */
170
+ private recordApiSuccess(): void {
171
+ if (!this.circuitBreakerRegistry) return;
172
+ this.circuitBreakerRegistry.get('jira').recordSuccess();
173
+ }
174
+
175
+ /**
176
+ * Record failure on circuit breaker and optionally enqueue retry.
177
+ */
178
+ private async recordApiFailure(error: unknown, operation: string): Promise<void> {
179
+ const httpStatus = (error as any)?.response?.status ?? 0;
180
+
181
+ if (this.circuitBreakerRegistry) {
182
+ this.circuitBreakerRegistry.get('jira').recordFailure();
183
+ }
184
+
185
+ if (this.retryQueue && httpStatus >= 500) {
186
+ await this.retryQueue.enqueue({
187
+ incrementId: this.incrementId,
188
+ provider: 'jira',
189
+ featureId: this.featureId,
190
+ projectPath: this.projectRoot,
191
+ projectName: this.config.projectKey,
192
+ error: `${httpStatus} ${operation}: ${(error as Error)?.message ?? String(error)}`,
193
+ });
194
+ }
195
+ }
196
+
116
197
  /**
117
198
  * Initialize: detect deployment type and update client baseURL
118
199
  */
@@ -130,6 +211,9 @@ export class JiraSpecSync {
130
211
  async syncSpecToJira(specId: string): Promise<SpecSyncResult> {
131
212
  console.log(`\nšŸ”„ Syncing spec ${specId} to Jira Epic...`);
132
213
 
214
+ return this.withLock(async () => {
215
+ this.checkCircuitBreaker();
216
+
133
217
  try {
134
218
  // 1. Load spec
135
219
  const spec = await this.specManager.loadSpec(specId);
@@ -168,6 +252,7 @@ export class JiraSpecSync {
168
252
  // 3. Sync user stories as Jira Stories
169
253
  const changes = await this.syncUserStories(epic.key, spec);
170
254
 
255
+ this.recordApiSuccess();
171
256
  console.log('āœ… Sync complete!');
172
257
 
173
258
  return {
@@ -180,6 +265,7 @@ export class JiraSpecSync {
180
265
  };
181
266
 
182
267
  } catch (error: any) {
268
+ await this.recordApiFailure(error, 'syncSpecToJira');
183
269
  const axiosData = error?.response?.data;
184
270
  const detail = axiosData ? JSON.stringify(axiosData) : '';
185
271
  console.error('āŒ Error syncing to Jira:', error?.message || error, detail ? `\n Response: ${detail}` : '');
@@ -190,6 +276,7 @@ export class JiraSpecSync {
190
276
  error: error instanceof Error ? error.message : 'Unknown error'
191
277
  };
192
278
  }
279
+ });
193
280
  }
194
281
 
195
282
  /**
@@ -198,6 +285,9 @@ export class JiraSpecSync {
198
285
  async syncFromJira(specId: string): Promise<SpecSyncResult> {
199
286
  console.log(`\nšŸ”„ Syncing FROM Jira to spec ${specId}...`);
200
287
 
288
+ return this.withLock(async () => {
289
+ this.checkCircuitBreaker();
290
+
201
291
  try {
202
292
  // 1. Load spec
203
293
  const spec = await this.specManager.loadSpec(specId);
@@ -258,6 +348,7 @@ export class JiraSpecSync {
258
348
  };
259
349
 
260
350
  } catch (error) {
351
+ await this.recordApiFailure(error, 'syncFromJira');
261
352
  console.error('āŒ Error syncing FROM Jira:', error);
262
353
  return {
263
354
  success: false,
@@ -266,6 +357,7 @@ export class JiraSpecSync {
266
357
  error: error instanceof Error ? error.message : 'Unknown error'
267
358
  };
268
359
  }
360
+ });
269
361
  }
270
362
 
271
363
  /**