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
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import https from 'https';
11
+ import { AdoRateLimiter } from './ado-rate-limiter.js';
11
12
 
12
13
  // ============================================================================
13
14
  // Types
@@ -20,6 +21,8 @@ export interface AdoConfig {
20
21
  workItemType?: 'Epic' | 'Feature' | 'User Story';
21
22
  areaPath?: string;
22
23
  iterationPath?: string;
24
+ /** Optional rate limiter instance (shared across client instances) */
25
+ rateLimiter?: AdoRateLimiter;
23
26
  }
24
27
 
25
28
  export interface WorkItem {
@@ -70,11 +73,13 @@ export class AdoClient {
70
73
  private config: AdoConfig;
71
74
  private baseUrl: string;
72
75
  private authHeader: string;
76
+ private rateLimiter?: AdoRateLimiter;
73
77
 
74
78
  constructor(config: AdoConfig) {
75
79
  this.config = config;
76
80
  this.baseUrl = `https://dev.azure.com/${config.organization}/${config.project}`;
77
-
81
+ this.rateLimiter = config.rateLimiter;
82
+
78
83
  // Basic Auth: base64(":PAT")
79
84
  this.authHeader = 'Basic ' + Buffer.from(`:${config.personalAccessToken}`).toString('base64');
80
85
  }
@@ -207,6 +212,21 @@ export class AdoClient {
207
212
  await this.request('DELETE', url);
208
213
  }
209
214
 
215
+ /**
216
+ * Pull the current state of a work item for bidirectional sync.
217
+ *
218
+ * @param id - ADO work item ID
219
+ * @returns state and last-modified timestamp
220
+ */
221
+ async pullWorkItemState(id: number): Promise<{ state: string; modifiedAt: Date }> {
222
+ const url = `${this.baseUrl}/_apis/wit/workitems/${id}?$select=System.State,System.ChangedDate&api-version=7.1`;
223
+ const item = await this.request<WorkItem>('GET', url);
224
+ return {
225
+ state: item.fields['System.State'],
226
+ modifiedAt: new Date(item.fields['System.ChangedDate']),
227
+ };
228
+ }
229
+
210
230
  // ==========================================================================
211
231
  // Comment Operations
212
232
  // ==========================================================================
@@ -275,6 +295,11 @@ export class AdoClient {
275
295
  body?: any,
276
296
  additionalHeaders?: Record<string, string>
277
297
  ): Promise<T> {
298
+ // Check rate limiter before making request
299
+ if (this.rateLimiter && !this.rateLimiter.consume()) {
300
+ throw new Error('ADO rate limit exceeded — try again later');
301
+ }
302
+
278
303
  return new Promise((resolve, reject) => {
279
304
  const urlObj = new URL(url);
280
305
 
@@ -308,6 +333,14 @@ export class AdoClient {
308
333
  reject(new Error(`Failed to parse JSON response: ${error}`));
309
334
  }
310
335
  } else {
336
+ // Handle 429 Too Many Requests — apply Retry-After
337
+ if (res.statusCode === 429 && this.rateLimiter) {
338
+ const retryAfter = parseInt(res.headers['retry-after'] as string, 10);
339
+ if (retryAfter > 0) {
340
+ this.rateLimiter.applyRetryAfter(retryAfter);
341
+ }
342
+ }
343
+
311
344
  let errorMessage = `ADO API error: ${res.statusCode} ${res.statusMessage}`;
312
345
  try {
313
346
  const errorData = JSON.parse(data);
@@ -317,7 +350,9 @@ export class AdoClient {
317
350
  } catch {
318
351
  // Ignore JSON parse errors for error responses
319
352
  }
320
- reject(new Error(errorMessage));
353
+ const err: any = new Error(errorMessage);
354
+ err.status = res.statusCode;
355
+ reject(err);
321
356
  }
322
357
  });
323
358
  });
@@ -0,0 +1,35 @@
1
+ function mapAdoStateToSpecweave(adoState) {
2
+ const map = {
3
+ "New": "planned",
4
+ "To Do": "planned",
5
+ "Active": "active",
6
+ "Doing": "active",
7
+ "In Progress": "active",
8
+ "Resolved": "completed",
9
+ "Closed": "completed",
10
+ "Done": "completed",
11
+ "Removed": "abandoned"
12
+ };
13
+ return map[adoState] ?? "active";
14
+ }
15
+ function pullAdoChanges(ctx) {
16
+ if (!ctx.canUpsertInternalItems) {
17
+ return { changed: false, reason: "canUpsertInternalItems is false" };
18
+ }
19
+ const mappedStatus = mapAdoStateToSpecweave(ctx.adoState);
20
+ if (mappedStatus === ctx.localStatus) {
21
+ return { changed: false, reason: "states are equivalent" };
22
+ }
23
+ if (ctx.adoModifiedAt.getTime() <= ctx.localUpdatedAt.getTime()) {
24
+ return { changed: false, reason: "local is newer" };
25
+ }
26
+ return {
27
+ changed: true,
28
+ newStatus: mappedStatus,
29
+ reason: `ADO state "${ctx.adoState}" is newer \u2014 updating to "${mappedStatus}"`
30
+ };
31
+ }
32
+ export {
33
+ mapAdoStateToSpecweave,
34
+ pullAdoChanges
35
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * ADO Pull Sync — Bidirectional state synchronization
3
+ *
4
+ * Implements last-write-wins conflict resolution for pulling
5
+ * ADO work item state changes back into SpecWeave metadata.
6
+ *
7
+ * @module ado-pull-sync
8
+ */
9
+
10
+ export interface PullSyncContext {
11
+ localStatus: string;
12
+ localUpdatedAt: Date;
13
+ adoState: string;
14
+ adoModifiedAt: Date;
15
+ workItemId: number;
16
+ canUpsertInternalItems: boolean;
17
+ }
18
+
19
+ export interface PullSyncResult {
20
+ changed: boolean;
21
+ newStatus?: string;
22
+ reason?: string;
23
+ }
24
+
25
+ /**
26
+ * Map ADO work item states to SpecWeave statuses.
27
+ *
28
+ * Covers Agile/Scrum/Basic process templates.
29
+ */
30
+ export function mapAdoStateToSpecweave(adoState: string): string {
31
+ const map: Record<string, string> = {
32
+ 'New': 'planned',
33
+ 'To Do': 'planned',
34
+ 'Active': 'active',
35
+ 'Doing': 'active',
36
+ 'In Progress': 'active',
37
+ 'Resolved': 'completed',
38
+ 'Closed': 'completed',
39
+ 'Done': 'completed',
40
+ 'Removed': 'abandoned',
41
+ };
42
+
43
+ return map[adoState] ?? 'active';
44
+ }
45
+
46
+ /**
47
+ * Determine if ADO changes should override local state.
48
+ *
49
+ * Uses last-write-wins: if ADO was modified more recently than local,
50
+ * and the mapped state differs, update local metadata.
51
+ */
52
+ export function pullAdoChanges(ctx: PullSyncContext): PullSyncResult {
53
+ if (!ctx.canUpsertInternalItems) {
54
+ return { changed: false, reason: 'canUpsertInternalItems is false' };
55
+ }
56
+
57
+ const mappedStatus = mapAdoStateToSpecweave(ctx.adoState);
58
+
59
+ // Same effective status — no change needed
60
+ if (mappedStatus === ctx.localStatus) {
61
+ return { changed: false, reason: 'states are equivalent' };
62
+ }
63
+
64
+ // Last-write-wins: ADO must be newer
65
+ if (ctx.adoModifiedAt.getTime() <= ctx.localUpdatedAt.getTime()) {
66
+ return { changed: false, reason: 'local is newer' };
67
+ }
68
+
69
+ return {
70
+ changed: true,
71
+ newStatus: mappedStatus,
72
+ reason: `ADO state "${ctx.adoState}" is newer — updating to "${mappedStatus}"`,
73
+ };
74
+ }
@@ -0,0 +1,56 @@
1
+ class AdoRateLimiter {
2
+ constructor(options) {
3
+ this.capacity = options?.capacity ?? 200;
4
+ this.windowMs = options?.windowMs ?? 6e4;
5
+ this.tokens = this.capacity;
6
+ this.windowStart = Date.now();
7
+ this.retryAfterUntil = 0;
8
+ }
9
+ /**
10
+ * Try to consume one token. Returns true if allowed, false if exhausted.
11
+ */
12
+ consume() {
13
+ this.refillIfWindowExpired();
14
+ if (Date.now() < this.retryAfterUntil) {
15
+ return false;
16
+ }
17
+ if (this.tokens <= 0) {
18
+ return false;
19
+ }
20
+ this.tokens--;
21
+ return true;
22
+ }
23
+ /**
24
+ * Number of tokens remaining in the current window.
25
+ */
26
+ remaining() {
27
+ this.refillIfWindowExpired();
28
+ return this.tokens;
29
+ }
30
+ /**
31
+ * Whether the bucket is fully exhausted (0 tokens remaining).
32
+ */
33
+ isExhausted() {
34
+ this.refillIfWindowExpired();
35
+ return this.tokens <= 0;
36
+ }
37
+ /**
38
+ * Apply a Retry-After delay (from ADO 429 response).
39
+ * Blocks all consumption until the delay expires.
40
+ *
41
+ * @param seconds - Number of seconds to wait
42
+ */
43
+ applyRetryAfter(seconds) {
44
+ this.retryAfterUntil = Date.now() + seconds * 1e3;
45
+ }
46
+ refillIfWindowExpired() {
47
+ const now = Date.now();
48
+ if (now - this.windowStart >= this.windowMs) {
49
+ this.tokens = this.capacity;
50
+ this.windowStart = now;
51
+ }
52
+ }
53
+ }
54
+ export {
55
+ AdoRateLimiter
56
+ };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * ADO Token Bucket Rate Limiter
3
+ *
4
+ * Simple token bucket that prevents exceeding Azure DevOps API rate limits.
5
+ * ADO's documented limit is ~200 requests per minute for PAT-based auth.
6
+ *
7
+ * Supports Retry-After header handling: when ADO returns 429, call
8
+ * applyRetryAfter(seconds) to block consumption until the cooldown expires.
9
+ *
10
+ * @module ado-rate-limiter
11
+ */
12
+
13
+ export interface AdoRateLimiterOptions {
14
+ /** Max tokens per window (default: 200) */
15
+ capacity?: number;
16
+ /** Window duration in ms (default: 60000 = 1 minute) */
17
+ windowMs?: number;
18
+ }
19
+
20
+ export class AdoRateLimiter {
21
+ private readonly capacity: number;
22
+ private readonly windowMs: number;
23
+ private tokens: number;
24
+ private windowStart: number;
25
+ private retryAfterUntil: number;
26
+
27
+ constructor(options?: AdoRateLimiterOptions) {
28
+ this.capacity = options?.capacity ?? 200;
29
+ this.windowMs = options?.windowMs ?? 60_000;
30
+ this.tokens = this.capacity;
31
+ this.windowStart = Date.now();
32
+ this.retryAfterUntil = 0;
33
+ }
34
+
35
+ /**
36
+ * Try to consume one token. Returns true if allowed, false if exhausted.
37
+ */
38
+ consume(): boolean {
39
+ this.refillIfWindowExpired();
40
+
41
+ if (Date.now() < this.retryAfterUntil) {
42
+ return false;
43
+ }
44
+
45
+ if (this.tokens <= 0) {
46
+ return false;
47
+ }
48
+
49
+ this.tokens--;
50
+ return true;
51
+ }
52
+
53
+ /**
54
+ * Number of tokens remaining in the current window.
55
+ */
56
+ remaining(): number {
57
+ this.refillIfWindowExpired();
58
+ return this.tokens;
59
+ }
60
+
61
+ /**
62
+ * Whether the bucket is fully exhausted (0 tokens remaining).
63
+ */
64
+ isExhausted(): boolean {
65
+ this.refillIfWindowExpired();
66
+ return this.tokens <= 0;
67
+ }
68
+
69
+ /**
70
+ * Apply a Retry-After delay (from ADO 429 response).
71
+ * Blocks all consumption until the delay expires.
72
+ *
73
+ * @param seconds - Number of seconds to wait
74
+ */
75
+ applyRetryAfter(seconds: number): void {
76
+ this.retryAfterUntil = Date.now() + seconds * 1000;
77
+ }
78
+
79
+ private refillIfWindowExpired(): void {
80
+ const now = Date.now();
81
+ if (now - this.windowStart >= this.windowMs) {
82
+ this.tokens = this.capacity;
83
+ this.windowStart = now;
84
+ }
85
+ }
86
+ }
@@ -1,11 +1,12 @@
1
1
  import { SpecMetadataManager } from "../../../src/core/specs/spec-metadata-manager.js";
2
2
  import { SpecParser } from "../../../src/core/specs/spec-parser.js";
3
+ import { SyncCircuitBreaker } from "../../../src/core/increment/sync-circuit-breaker.js";
3
4
  import axios from "axios";
4
5
  import { promises as fsPromises, existsSync } from "fs";
5
6
  import path from "path";
6
7
  import yaml from "yaml";
7
8
  class AdoSpecSync {
8
- constructor(config, projectRoot = process.cwd(), projectId) {
9
+ constructor(config, projectRoot = process.cwd(), projectId, breaker) {
9
10
  this.availableTypes = null;
10
11
  /**
11
12
  * Resolve a work item state name for the given type.
@@ -18,6 +19,7 @@ class AdoSpecSync {
18
19
  this.projectRoot = projectRoot;
19
20
  this.specManager = new SpecMetadataManager(projectRoot, projectId);
20
21
  this.config = config;
22
+ this.breaker = breaker ?? new SyncCircuitBreaker();
21
23
  this.client = axios.create({
22
24
  baseURL: `https://dev.azure.com/${config.organization}/${config.project}/_apis`,
23
25
  auth: {
@@ -93,6 +95,14 @@ class AdoSpecSync {
93
95
  async syncSpecToAdo(specId) {
94
96
  console.log(`
95
97
  \u{1F504} Syncing spec ${specId} to ADO Feature...`);
98
+ if (!this.breaker.canSync()) {
99
+ return {
100
+ success: false,
101
+ specId,
102
+ provider: "ado",
103
+ error: "Circuit breaker open \u2014 sync blocked"
104
+ };
105
+ }
96
106
  try {
97
107
  const spec = await this.specManager.loadSpec(specId);
98
108
  if (!spec) {
@@ -144,6 +154,14 @@ class AdoSpecSync {
144
154
  async syncFromAdo(specId) {
145
155
  console.log(`
146
156
  \u{1F504} Syncing FROM ADO to spec ${specId}...`);
157
+ if (!this.breaker.canSync()) {
158
+ return {
159
+ success: false,
160
+ specId,
161
+ provider: "ado",
162
+ error: "Circuit breaker open \u2014 sync blocked"
163
+ };
164
+ }
147
165
  try {
148
166
  const spec = await this.specManager.loadSpec(specId);
149
167
  if (!spec) {
@@ -651,6 +669,12 @@ ${acList}
651
669
  };
652
670
  return map[normalizedType] || defaultType;
653
671
  }
672
+ /**
673
+ * Check whether the circuit is closed (sync allowed).
674
+ */
675
+ canSync() {
676
+ return this.breaker.canSync();
677
+ }
654
678
  }
655
679
  export {
656
680
  AdoSpecSync
@@ -22,7 +22,10 @@ import {
22
22
  SpecSyncConflict
23
23
  } from '../../../src/core/types/spec-metadata.js';
24
24
  import { execFileNoThrow } from '../../../src/utils/execFileNoThrow.js';
25
- import axios, { AxiosInstance } from 'axios';
25
+ import { SyncCircuitBreaker } from '../../../src/core/increment/sync-circuit-breaker.js';
26
+ import { withRetry } from '../../../src/core/sync/retry-wrapper.js';
27
+ import { SyncError } from '../../../src/core/errors/sync-error.js';
28
+ import axios, { AxiosInstance, AxiosError } from 'axios';
26
29
  import { promises as fsPromises, existsSync } from 'fs';
27
30
  import path from 'path';
28
31
  import yaml from 'yaml';
@@ -62,11 +65,13 @@ export class AdoSpecSync {
62
65
  private config: AdoConfig;
63
66
  private projectRoot: string;
64
67
  private availableTypes: Set<string> | null = null;
68
+ private breaker: SyncCircuitBreaker;
65
69
 
66
- constructor(config: AdoConfig, projectRoot: string = process.cwd(), projectId?: string) {
70
+ constructor(config: AdoConfig, projectRoot: string = process.cwd(), projectId?: string, breaker?: SyncCircuitBreaker) {
67
71
  this.projectRoot = projectRoot;
68
72
  this.specManager = new SpecMetadataManager(projectRoot, projectId);
69
73
  this.config = config;
74
+ this.breaker = breaker ?? new SyncCircuitBreaker();
70
75
 
71
76
  // Create ADO API client
72
77
  // NOTE: Do NOT set a default Content-Type here. Work item create/update
@@ -166,6 +171,15 @@ export class AdoSpecSync {
166
171
  async syncSpecToAdo(specId: string): Promise<SpecSyncResult> {
167
172
  console.log(`\nšŸ”„ Syncing spec ${specId} to ADO Feature...`);
168
173
 
174
+ if (!this.breaker.canSync()) {
175
+ return {
176
+ success: false,
177
+ specId,
178
+ provider: 'ado',
179
+ error: 'Circuit breaker open — sync blocked',
180
+ };
181
+ }
182
+
169
183
  try {
170
184
  // 1. Load spec
171
185
  const spec = await this.specManager.loadSpec(specId);
@@ -232,6 +246,15 @@ export class AdoSpecSync {
232
246
  async syncFromAdo(specId: string): Promise<SpecSyncResult> {
233
247
  console.log(`\nšŸ”„ Syncing FROM ADO to spec ${specId}...`);
234
248
 
249
+ if (!this.breaker.canSync()) {
250
+ return {
251
+ success: false,
252
+ specId,
253
+ provider: 'ado',
254
+ error: 'Circuit breaker open — sync blocked',
255
+ };
256
+ }
257
+
235
258
  try {
236
259
  // 1. Load spec
237
260
  const spec = await this.specManager.loadSpec(specId);
@@ -860,4 +883,11 @@ ${acList}
860
883
 
861
884
  return map[normalizedType] || defaultType;
862
885
  }
886
+
887
+ /**
888
+ * Check whether the circuit is closed (sync allowed).
889
+ */
890
+ canSync(): boolean {
891
+ return this.breaker.canSync();
892
+ }
863
893
  }
@@ -1,8 +1,12 @@
1
1
  import axios from "axios";
2
+ import { SyncCircuitBreaker } from "../../../src/core/increment/sync-circuit-breaker.js";
3
+ import { withRetry } from "../../../src/core/sync/retry-wrapper.js";
4
+ import { SyncError } from "../../../src/core/errors/sync-error.js";
2
5
  class AdoStatusSync {
3
- constructor(organization, project, personalAccessToken) {
6
+ constructor(organization, project, personalAccessToken, breaker) {
4
7
  this.organization = organization;
5
8
  this.project = project;
9
+ this.breaker = breaker ?? new SyncCircuitBreaker();
6
10
  this.client = axios.create({
7
11
  baseURL: `https://dev.azure.com/${organization}/${project}/_apis`,
8
12
  auth: {
@@ -23,12 +27,12 @@ class AdoStatusSync {
23
27
  * @returns Current work item state
24
28
  */
25
29
  async getStatus(workItemId) {
26
- const response = await this.client.get(
27
- `/wit/workitems/${workItemId}?api-version=7.0`
30
+ this.assertCircuitClosed();
31
+ return this.withResilienceWrapper(
32
+ () => this.client.get(`/wit/workitems/${workItemId}?api-version=7.0`).then((response) => ({
33
+ state: response.data.fields["System.State"]
34
+ }))
28
35
  );
29
- return {
30
- state: response.data.fields["System.State"]
31
- };
32
36
  }
33
37
  /**
34
38
  * Update ADO work item state and tags
@@ -40,6 +44,7 @@ class AdoStatusSync {
40
44
  * @param status - Desired status with state and optional tags
41
45
  */
42
46
  async updateStatus(workItemId, status) {
47
+ this.assertCircuitClosed();
43
48
  const patch = [
44
49
  {
45
50
  op: "add",
@@ -60,11 +65,16 @@ class AdoStatusSync {
60
65
  value: allTags.join("; ")
61
66
  });
62
67
  }
63
- await this.client.patch(
64
- `/wit/workitems/${workItemId}?api-version=7.0`,
65
- patch
68
+ await this.withResilienceWrapper(
69
+ () => this.client.patch(`/wit/workitems/${workItemId}?api-version=7.0`, patch)
66
70
  );
67
71
  }
72
+ /**
73
+ * Check whether the circuit is closed (sync allowed).
74
+ */
75
+ canSync() {
76
+ return this.breaker.canSync();
77
+ }
68
78
  /**
69
79
  * Get current tags from ADO work item
70
80
  *
@@ -91,6 +101,7 @@ class AdoStatusSync {
91
101
  * @param newStatus - New SpecWeave status
92
102
  */
93
103
  async postStatusComment(workItemId, oldStatus, newStatus) {
104
+ this.assertCircuitClosed();
94
105
  const text = `\u{1F504} Status Update
95
106
 
96
107
  SpecWeave status changed:
@@ -99,13 +110,40 @@ SpecWeave status changed:
99
110
  \u2022 When: ${(/* @__PURE__ */ new Date()).toISOString()}
100
111
 
101
112
  Synced from SpecWeave`;
102
- await this.client.post(
103
- `/wit/workitems/${workItemId}/comments?api-version=7.0-preview.3`,
104
- {
105
- text
106
- }
113
+ await this.withResilienceWrapper(
114
+ () => this.client.post(
115
+ `/wit/workitems/${workItemId}/comments?api-version=7.0-preview.3`,
116
+ { text }
117
+ )
107
118
  );
108
119
  }
120
+ /**
121
+ * Assert the circuit is closed. Throws CircuitOpenError if open.
122
+ */
123
+ assertCircuitClosed() {
124
+ if (!this.breaker.canSync()) {
125
+ throw new SyncError("ado", 503, "", "Circuit breaker open \u2014 sync blocked");
126
+ }
127
+ }
128
+ /**
129
+ * Wrap an async operation with retry + circuit breaker recording.
130
+ */
131
+ async withResilienceWrapper(fn) {
132
+ try {
133
+ const result = await withRetry(fn, { maxRetries: 3, baseMs: 500, maxMs: 5e3 });
134
+ this.breaker.recordSuccess();
135
+ return result;
136
+ } catch (error) {
137
+ this.breaker.recordFailure();
138
+ if (error?.response?.status) {
139
+ const status = error.response.status;
140
+ const body = typeof error.response.data === "string" ? error.response.data : JSON.stringify(error.response.data ?? "");
141
+ const detail = error.response.statusText || "Unknown";
142
+ throw new SyncError("ado", status, body, detail);
143
+ }
144
+ throw error;
145
+ }
146
+ }
109
147
  }
110
148
  export {
111
149
  AdoStatusSync