specweave 1.0.488 → 1.0.489

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 (147) 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 +10 -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 +14 -0
  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/skills/code-reviewer/agents/reviewer-silent-failures.md +65 -0
  123. package/plugins/specweave/skills/code-reviewer/agents/reviewer-spec-compliance.md +83 -0
  124. package/plugins/specweave/skills/code-reviewer/agents/reviewer-types.md +68 -0
  125. package/plugins/specweave/skills/skill-gen/SKILL.md +20 -3
  126. package/plugins/specweave/skills/team-lead/agents/architect.md +52 -0
  127. package/plugins/specweave/skills/team-lead/agents/pm.md +50 -0
  128. package/plugins/specweave/skills/team-lead/agents/researcher.md +64 -0
  129. package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js +23 -21
  130. package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.ts +37 -29
  131. package/plugins/specweave-ado/lib/ado-client.js +14 -0
  132. package/plugins/specweave-ado/lib/ado-client.ts +18 -0
  133. package/plugins/specweave-ado/lib/ado-pull-sync.js +35 -0
  134. package/plugins/specweave-ado/lib/ado-pull-sync.ts +74 -0
  135. package/plugins/specweave-ado/lib/ado-rate-limiter.js +56 -0
  136. package/plugins/specweave-ado/lib/ado-rate-limiter.ts +86 -0
  137. package/plugins/specweave-ado/lib/ado-spec-sync.js +25 -1
  138. package/plugins/specweave-ado/lib/ado-spec-sync.ts +32 -2
  139. package/plugins/specweave-ado/lib/ado-status-sync.js +52 -14
  140. package/plugins/specweave-ado/lib/ado-status-sync.ts +64 -16
  141. package/plugins/specweave-github/lib/github-client-v2.ts +1 -1
  142. package/plugins/specweave-github/lib/github-push-sync.js +11 -3
  143. package/plugins/specweave-github/lib/github-push-sync.ts +16 -3
  144. package/plugins/specweave-jira/lib/jira-spec-sync.js +60 -1
  145. package/plugins/specweave-jira/lib/jira-spec-sync.ts +93 -1
  146. package/plugins/specweave-jira/lib/jira-status-sync.js +151 -109
  147. package/plugins/specweave-jira/lib/jira-status-sync.ts +161 -39
@@ -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
@@ -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);