specweave 1.0.351 → 1.0.352

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 (211) hide show
  1. package/bin/specweave.js +9 -0
  2. package/dist/plugins/specweave-ado/lib/ado-client-v2.d.ts +5 -0
  3. package/dist/plugins/specweave-ado/lib/ado-client-v2.d.ts.map +1 -1
  4. package/dist/plugins/specweave-ado/lib/ado-client-v2.js +61 -23
  5. package/dist/plugins/specweave-ado/lib/ado-client-v2.js.map +1 -1
  6. package/dist/plugins/specweave-ado/lib/ado-duplicate-detector.d.ts.map +1 -1
  7. package/dist/plugins/specweave-ado/lib/ado-duplicate-detector.js +3 -2
  8. package/dist/plugins/specweave-ado/lib/ado-duplicate-detector.js.map +1 -1
  9. package/dist/plugins/specweave-ado/lib/ado-profile-resolver.d.ts.map +1 -1
  10. package/dist/plugins/specweave-ado/lib/ado-profile-resolver.js +2 -1
  11. package/dist/plugins/specweave-ado/lib/ado-profile-resolver.js.map +1 -1
  12. package/dist/plugins/specweave-ado/lib/ado-spec-sync.d.ts +1 -1
  13. package/dist/plugins/specweave-ado/lib/ado-spec-sync.d.ts.map +1 -1
  14. package/dist/plugins/specweave-ado/lib/ado-spec-sync.js +25 -9
  15. package/dist/plugins/specweave-ado/lib/ado-spec-sync.js.map +1 -1
  16. package/dist/plugins/specweave-ado/lib/conflict-resolver.d.ts.map +1 -1
  17. package/dist/plugins/specweave-ado/lib/conflict-resolver.js +17 -1
  18. package/dist/plugins/specweave-ado/lib/conflict-resolver.js.map +1 -1
  19. package/dist/plugins/specweave-ado/lib/per-us-sync.d.ts +3 -0
  20. package/dist/plugins/specweave-ado/lib/per-us-sync.d.ts.map +1 -1
  21. package/dist/plugins/specweave-ado/lib/per-us-sync.js +14 -1
  22. package/dist/plugins/specweave-ado/lib/per-us-sync.js.map +1 -1
  23. package/dist/plugins/specweave-github/lib/github-client-v2.d.ts.map +1 -1
  24. package/dist/plugins/specweave-github/lib/github-client-v2.js +10 -7
  25. package/dist/plugins/specweave-github/lib/github-client-v2.js.map +1 -1
  26. package/dist/plugins/specweave-github/lib/github-client.d.ts +1 -1
  27. package/dist/plugins/specweave-github/lib/github-client.d.ts.map +1 -1
  28. package/dist/plugins/specweave-github/lib/github-client.js +7 -5
  29. package/dist/plugins/specweave-github/lib/github-client.js.map +1 -1
  30. package/dist/plugins/specweave-github/lib/github-cross-repo-sync.js +13 -3
  31. package/dist/plugins/specweave-github/lib/github-cross-repo-sync.js.map +1 -1
  32. package/dist/plugins/specweave-github/lib/github-feature-sync-cli.d.ts +24 -1
  33. package/dist/plugins/specweave-github/lib/github-feature-sync-cli.d.ts.map +1 -1
  34. package/dist/plugins/specweave-github/lib/github-feature-sync-cli.js +36 -20
  35. package/dist/plugins/specweave-github/lib/github-feature-sync-cli.js.map +1 -1
  36. package/dist/plugins/specweave-github/lib/github-graphql-client.d.ts +1 -0
  37. package/dist/plugins/specweave-github/lib/github-graphql-client.d.ts.map +1 -1
  38. package/dist/plugins/specweave-github/lib/github-graphql-client.js +32 -22
  39. package/dist/plugins/specweave-github/lib/github-graphql-client.js.map +1 -1
  40. package/dist/plugins/specweave-github/lib/github-spec-frontmatter-updater.js +144 -8
  41. package/dist/plugins/specweave-github/lib/github-spec-frontmatter-updater.js.map +1 -1
  42. package/dist/plugins/specweave-github/lib/github-spec-sync.d.ts +8 -1
  43. package/dist/plugins/specweave-github/lib/github-spec-sync.d.ts.map +1 -1
  44. package/dist/plugins/specweave-github/lib/github-spec-sync.js +94 -24
  45. package/dist/plugins/specweave-github/lib/github-spec-sync.js.map +1 -1
  46. package/dist/plugins/specweave-github/lib/github-sync-orchestrator.d.ts +1 -0
  47. package/dist/plugins/specweave-github/lib/github-sync-orchestrator.d.ts.map +1 -1
  48. package/dist/plugins/specweave-github/lib/github-sync-orchestrator.js +2 -1
  49. package/dist/plugins/specweave-github/lib/github-sync-orchestrator.js.map +1 -1
  50. package/dist/plugins/specweave-github/lib/github-us-auto-closer.d.ts.map +1 -1
  51. package/dist/plugins/specweave-github/lib/github-us-auto-closer.js +25 -0
  52. package/dist/plugins/specweave-github/lib/github-us-auto-closer.js.map +1 -1
  53. package/dist/plugins/specweave-github/lib/per-us-sync.d.ts +3 -0
  54. package/dist/plugins/specweave-github/lib/per-us-sync.d.ts.map +1 -1
  55. package/dist/plugins/specweave-github/lib/per-us-sync.js +29 -9
  56. package/dist/plugins/specweave-github/lib/per-us-sync.js.map +1 -1
  57. package/dist/plugins/specweave-jira/lib/content-format-adapter.d.ts +59 -0
  58. package/dist/plugins/specweave-jira/lib/content-format-adapter.d.ts.map +1 -0
  59. package/dist/plugins/specweave-jira/lib/content-format-adapter.js +159 -0
  60. package/dist/plugins/specweave-jira/lib/content-format-adapter.js.map +1 -0
  61. package/dist/plugins/specweave-jira/lib/jira-deployment-detector.d.ts +45 -0
  62. package/dist/plugins/specweave-jira/lib/jira-deployment-detector.d.ts.map +1 -0
  63. package/dist/plugins/specweave-jira/lib/jira-deployment-detector.js +92 -0
  64. package/dist/plugins/specweave-jira/lib/jira-deployment-detector.js.map +1 -0
  65. package/dist/plugins/specweave-jira/lib/jira-duplicate-detector.d.ts.map +1 -1
  66. package/dist/plugins/specweave-jira/lib/jira-duplicate-detector.js +13 -28
  67. package/dist/plugins/specweave-jira/lib/jira-duplicate-detector.js.map +1 -1
  68. package/dist/plugins/specweave-jira/lib/jira-epic-sync.d.ts +2 -1
  69. package/dist/plugins/specweave-jira/lib/jira-epic-sync.d.ts.map +1 -1
  70. package/dist/plugins/specweave-jira/lib/jira-epic-sync.js +19 -7
  71. package/dist/plugins/specweave-jira/lib/jira-epic-sync.js.map +1 -1
  72. package/dist/plugins/specweave-jira/lib/jira-field-discovery.d.ts +47 -0
  73. package/dist/plugins/specweave-jira/lib/jira-field-discovery.d.ts.map +1 -0
  74. package/dist/plugins/specweave-jira/lib/jira-field-discovery.js +110 -0
  75. package/dist/plugins/specweave-jira/lib/jira-field-discovery.js.map +1 -0
  76. package/dist/plugins/specweave-jira/lib/jira-paginated-search.d.ts +26 -0
  77. package/dist/plugins/specweave-jira/lib/jira-paginated-search.d.ts.map +1 -0
  78. package/dist/plugins/specweave-jira/lib/jira-paginated-search.js +77 -0
  79. package/dist/plugins/specweave-jira/lib/jira-paginated-search.js.map +1 -0
  80. package/dist/plugins/specweave-jira/lib/jira-spec-commit-sync.d.ts.map +1 -1
  81. package/dist/plugins/specweave-jira/lib/jira-spec-commit-sync.js +5 -3
  82. package/dist/plugins/specweave-jira/lib/jira-spec-commit-sync.js.map +1 -1
  83. package/dist/plugins/specweave-jira/lib/jira-spec-sync.d.ts +17 -2
  84. package/dist/plugins/specweave-jira/lib/jira-spec-sync.d.ts.map +1 -1
  85. package/dist/plugins/specweave-jira/lib/jira-spec-sync.js +103 -33
  86. package/dist/plugins/specweave-jira/lib/jira-spec-sync.js.map +1 -1
  87. package/dist/plugins/specweave-jira/lib/jira-status-sync.d.ts +4 -0
  88. package/dist/plugins/specweave-jira/lib/jira-status-sync.d.ts.map +1 -1
  89. package/dist/plugins/specweave-jira/lib/jira-status-sync.js +19 -6
  90. package/dist/plugins/specweave-jira/lib/jira-status-sync.js.map +1 -1
  91. package/dist/plugins/specweave-jira/lib/metadata-paths.d.ts +29 -0
  92. package/dist/plugins/specweave-jira/lib/metadata-paths.d.ts.map +1 -0
  93. package/dist/plugins/specweave-jira/lib/metadata-paths.js +73 -0
  94. package/dist/plugins/specweave-jira/lib/metadata-paths.js.map +1 -0
  95. package/dist/plugins/specweave-jira/lib/reorganization-detector.d.ts +15 -2
  96. package/dist/plugins/specweave-jira/lib/reorganization-detector.d.ts.map +1 -1
  97. package/dist/plugins/specweave-jira/lib/reorganization-detector.js +121 -33
  98. package/dist/plugins/specweave-jira/lib/reorganization-detector.js.map +1 -1
  99. package/dist/src/cli/commands/sync-progress.d.ts +6 -0
  100. package/dist/src/cli/commands/sync-progress.d.ts.map +1 -1
  101. package/dist/src/cli/commands/sync-progress.js +37 -0
  102. package/dist/src/cli/commands/sync-progress.js.map +1 -1
  103. package/dist/src/cli/commands/sync-task.d.ts +16 -0
  104. package/dist/src/cli/commands/sync-task.d.ts.map +1 -0
  105. package/dist/src/cli/commands/sync-task.js +42 -0
  106. package/dist/src/cli/commands/sync-task.js.map +1 -0
  107. package/dist/src/core/hooks/LifecycleHookDispatcher.d.ts +9 -1
  108. package/dist/src/core/hooks/LifecycleHookDispatcher.d.ts.map +1 -1
  109. package/dist/src/core/hooks/LifecycleHookDispatcher.js +26 -8
  110. package/dist/src/core/hooks/LifecycleHookDispatcher.js.map +1 -1
  111. package/dist/src/core/increment/metadata-manager.d.ts.map +1 -1
  112. package/dist/src/core/increment/metadata-manager.js +26 -0
  113. package/dist/src/core/increment/metadata-manager.js.map +1 -1
  114. package/dist/src/core/increment/status-commands.d.ts.map +1 -1
  115. package/dist/src/core/increment/status-commands.js +33 -11
  116. package/dist/src/core/increment/status-commands.js.map +1 -1
  117. package/dist/src/locales/de/cli.json +245 -77
  118. package/dist/src/locales/es/cli.json +238 -3
  119. package/dist/src/locales/fr/cli.json +254 -86
  120. package/dist/src/locales/ja/cli.json +246 -78
  121. package/dist/src/locales/ko/cli.json +246 -78
  122. package/dist/src/locales/pt/cli.json +245 -77
  123. package/dist/src/locales/zh/cli.json +251 -83
  124. package/dist/src/sync/github-reconciler.d.ts +30 -0
  125. package/dist/src/sync/github-reconciler.d.ts.map +1 -1
  126. package/dist/src/sync/github-reconciler.js +235 -1
  127. package/dist/src/sync/github-reconciler.js.map +1 -1
  128. package/package.json +1 -1
  129. package/plugins/specweave/hooks/v2/guards/task-ac-sync-guard.sh +31 -0
  130. package/plugins/specweave/lib/vendor/core/increment/metadata-manager.js +26 -0
  131. package/plugins/specweave/lib/vendor/core/increment/metadata-manager.js.map +1 -1
  132. package/plugins/specweave/lib/vendor/sync/github-reconciler.d.ts +30 -0
  133. package/plugins/specweave/lib/vendor/sync/github-reconciler.js +235 -1
  134. package/plugins/specweave/lib/vendor/sync/github-reconciler.js.map +1 -1
  135. package/plugins/specweave/skills/team-lead/agents/security.md +0 -1
  136. package/plugins/specweave-ado/hooks/post-task-completion.sh +2 -2
  137. package/plugins/specweave-ado/lib/ado-client-v2.js +51 -21
  138. package/plugins/specweave-ado/lib/ado-client-v2.ts +62 -23
  139. package/plugins/specweave-ado/lib/ado-duplicate-detector.js +4 -4
  140. package/plugins/specweave-ado/lib/ado-duplicate-detector.ts +4 -3
  141. package/plugins/specweave-ado/lib/ado-hierarchical-sync.js +54 -12
  142. package/plugins/specweave-ado/lib/ado-hierarchical-sync.ts +88 -18
  143. package/plugins/specweave-ado/lib/ado-profile-resolver.js +1 -1
  144. package/plugins/specweave-ado/lib/ado-profile-resolver.ts +3 -1
  145. package/plugins/specweave-ado/lib/ado-spec-sync.js +22 -9
  146. package/plugins/specweave-ado/lib/ado-spec-sync.ts +27 -9
  147. package/plugins/specweave-ado/lib/conflict-resolver.js +17 -1
  148. package/plugins/specweave-ado/lib/conflict-resolver.ts +17 -1
  149. package/plugins/specweave-ado/lib/enhanced-ado-sync.js +11 -1
  150. package/plugins/specweave-ado/lib/per-us-sync.js +8 -1
  151. package/plugins/specweave-ado/lib/per-us-sync.ts +17 -2
  152. package/plugins/specweave-github/hooks/github-auto-create-handler.sh +28 -2
  153. package/plugins/specweave-github/hooks/post-task-completion.sh +6 -3
  154. package/plugins/specweave-github/lib/enhanced-github-sync.js +35 -6
  155. package/plugins/specweave-github/lib/github-board-resolver.js +4 -4
  156. package/plugins/specweave-github/lib/github-board-resolver.ts +4 -4
  157. package/plugins/specweave-github/lib/github-client-v2.js +6 -6
  158. package/plugins/specweave-github/lib/github-client-v2.ts +11 -7
  159. package/plugins/specweave-github/lib/github-client.js +5 -4
  160. package/plugins/specweave-github/lib/github-client.ts +7 -5
  161. package/plugins/specweave-github/lib/github-cross-repo-sync.js +17 -3
  162. package/plugins/specweave-github/lib/github-cross-repo-sync.ts +16 -3
  163. package/plugins/specweave-github/lib/github-feature-sync-cli.js +20 -11
  164. package/plugins/specweave-github/lib/github-feature-sync-cli.ts +42 -20
  165. package/plugins/specweave-github/lib/github-graphql-client.js +29 -20
  166. package/plugins/specweave-github/lib/github-graphql-client.ts +34 -22
  167. package/plugins/specweave-github/lib/github-hierarchical-sync.js +2 -2
  168. package/plugins/specweave-github/lib/github-hierarchical-sync.ts +2 -2
  169. package/plugins/specweave-github/lib/github-multi-project-sync.js +23 -7
  170. package/plugins/specweave-github/lib/github-multi-project-sync.ts +26 -8
  171. package/plugins/specweave-github/lib/github-spec-frontmatter-updater.js +110 -5
  172. package/plugins/specweave-github/lib/github-spec-frontmatter-updater.ts +135 -9
  173. package/plugins/specweave-github/lib/github-spec-sync.js +85 -24
  174. package/plugins/specweave-github/lib/github-spec-sync.ts +100 -26
  175. package/plugins/specweave-github/lib/github-sync-orchestrator.js +2 -1
  176. package/plugins/specweave-github/lib/github-sync-orchestrator.ts +3 -1
  177. package/plugins/specweave-github/lib/github-us-auto-closer.js +25 -0
  178. package/plugins/specweave-github/lib/github-us-auto-closer.ts +43 -0
  179. package/plugins/specweave-github/lib/per-us-sync.js +26 -11
  180. package/plugins/specweave-github/lib/per-us-sync.ts +29 -11
  181. package/plugins/specweave-jira/hooks/post-task-completion.sh +2 -1
  182. package/plugins/specweave-jira/lib/content-format-adapter.js +116 -0
  183. package/plugins/specweave-jira/lib/content-format-adapter.ts +189 -0
  184. package/plugins/specweave-jira/lib/enhanced-jira-sync.js +21 -5
  185. package/plugins/specweave-jira/lib/jira-deployment-detector.js +63 -0
  186. package/plugins/specweave-jira/lib/jira-deployment-detector.ts +113 -0
  187. package/plugins/specweave-jira/lib/jira-duplicate-detector.js +12 -29
  188. package/plugins/specweave-jira/lib/jira-duplicate-detector.ts +13 -27
  189. package/plugins/specweave-jira/lib/jira-epic-sync.js +15 -5
  190. package/plugins/specweave-jira/lib/jira-epic-sync.ts +22 -7
  191. package/plugins/specweave-jira/lib/jira-field-discovery.js +76 -0
  192. package/plugins/specweave-jira/lib/jira-field-discovery.ts +139 -0
  193. package/plugins/specweave-jira/lib/jira-hierarchical-sync.js +10 -0
  194. package/plugins/specweave-jira/lib/jira-hierarchical-sync.ts +11 -0
  195. package/plugins/specweave-jira/lib/jira-multi-project-sync.js +19 -9
  196. package/plugins/specweave-jira/lib/jira-multi-project-sync.ts +25 -14
  197. package/plugins/specweave-jira/lib/jira-paginated-search.js +55 -0
  198. package/plugins/specweave-jira/lib/jira-paginated-search.ts +108 -0
  199. package/plugins/specweave-jira/lib/jira-spec-commit-sync.js +5 -3
  200. package/plugins/specweave-jira/lib/jira-spec-commit-sync.ts +5 -3
  201. package/plugins/specweave-jira/lib/jira-spec-sync.js +102 -31
  202. package/plugins/specweave-jira/lib/jira-spec-sync.ts +123 -45
  203. package/plugins/specweave-jira/lib/jira-status-sync.js +18 -5
  204. package/plugins/specweave-jira/lib/jira-status-sync.ts +21 -6
  205. package/plugins/specweave-jira/lib/metadata-paths.js +38 -0
  206. package/plugins/specweave-jira/lib/metadata-paths.ts +73 -0
  207. package/plugins/specweave-jira/lib/reorganization-detector.js +101 -23
  208. package/plugins/specweave-jira/lib/reorganization-detector.ts +125 -35
  209. package/plugins/specweave-jira/scripts/refresh-cache.js +1 -1
  210. package/plugins/specweave-jira/scripts/refresh-cache.ts +2 -2
  211. package/plugins/specweave-jira/skills/jira-resource-validator/SKILL.md +3 -5
@@ -33,16 +33,15 @@ async function buildHierarchicalWIQL(organization, pat, containers) {
33
33
  );
34
34
  }
35
35
  }
36
+ if (container.filters) {
37
+ const containerFilterClauses = buildFilterClauses(container.filters);
38
+ if (containerFilterClauses.length > 0) {
39
+ parts.push(...containerFilterClauses);
40
+ }
41
+ }
36
42
  projectClauses.push(`(${parts.join(" AND ")})`);
37
43
  }
38
44
  let whereClause = projectClauses.join(" OR ");
39
- const filters = containers[0]?.filters;
40
- if (filters) {
41
- const filterClauses = buildFilterClauses(filters);
42
- if (filterClauses.length > 0) {
43
- whereClause = `(${whereClause}) AND ${filterClauses.join(" AND ")}`;
44
- }
45
- }
46
45
  return `
47
46
  SELECT [System.Id], [System.Title], [System.Description], [System.State],
48
47
  [System.CreatedDate], [System.ChangedDate], [System.WorkItemType],
@@ -91,11 +90,18 @@ function addTimeRangeFilter(wiql, timeRange) {
91
90
  }
92
91
  const { since, until } = calculateTimeRange(timeRange);
93
92
  const timeFilter = `[System.CreatedDate] >= '${since}' AND [System.CreatedDate] <= '${until}'`;
94
- if (wiql.includes("ORDER BY")) {
95
- return wiql.replace("ORDER BY", `AND ${timeFilter} ORDER BY`);
96
- } else {
93
+ const orderByRegex = /\bORDER\s+BY\b/i;
94
+ const match = wiql.match(orderByRegex);
95
+ if (match && match.index !== void 0) {
96
+ const before = wiql.substring(0, match.index).trimEnd();
97
+ const after = wiql.substring(match.index);
98
+ return `${before} AND ${timeFilter} ${after}`;
99
+ }
100
+ const whereRegex = /\bWHERE\b/i;
101
+ if (whereRegex.test(wiql)) {
97
102
  return `${wiql} AND ${timeFilter}`;
98
103
  }
104
+ return `${wiql} WHERE ${timeFilter}`;
99
105
  }
100
106
  function calculateTimeRange(timeRange) {
101
107
  const now = /* @__PURE__ */ new Date();
@@ -179,6 +185,10 @@ async function fetchWorkItemsFiltered(config, pat, timeRange) {
179
185
  const baseWiql = await buildHierarchicalWIQL(organization, pat, containers);
180
186
  const wiql = addTimeRangeFilter(baseWiql, timeRange);
181
187
  console.log("\u{1F50D} Fetching work items (FILTERED strategy):", wiql);
188
+ const uniqueProjects = new Set(containers.map((c) => c.id));
189
+ if (uniqueProjects.size > 1) {
190
+ return executeQueryOrgLevel(organization, pat, wiql);
191
+ }
182
192
  const project = containers[0].id;
183
193
  return executeQuery(organization, project, pat, wiql);
184
194
  }
@@ -208,7 +218,34 @@ async function executeQuery(organization, project, pat, wiql) {
208
218
  });
209
219
  return response.value || [];
210
220
  }
211
- function makeRequest(url, pat, method = "GET", body) {
221
+ async function executeQueryOrgLevel(organization, pat, wiql) {
222
+ const baseUrl = `https://dev.azure.com/${organization}`;
223
+ const queryUrl = `${baseUrl}/_apis/wit/wiql?api-version=7.1`;
224
+ const queryResult = await makeRequest(queryUrl, pat, "POST", { query: wiql });
225
+ if (!queryResult.workItems || queryResult.workItems.length === 0) {
226
+ return [];
227
+ }
228
+ const ids = queryResult.workItems.map((wi) => wi.id);
229
+ const batchUrl = `${baseUrl}/_apis/wit/workitemsbatch?api-version=7.1`;
230
+ const response = await makeRequest(batchUrl, pat, "POST", {
231
+ ids,
232
+ fields: [
233
+ "System.Id",
234
+ "System.Title",
235
+ "System.Description",
236
+ "System.State",
237
+ "System.CreatedDate",
238
+ "System.ChangedDate",
239
+ "System.WorkItemType",
240
+ "System.AreaPath",
241
+ "System.IterationPath",
242
+ "System.Tags"
243
+ ]
244
+ });
245
+ return response.value || [];
246
+ }
247
+ const DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
248
+ function makeRequest(url, pat, method = "GET", body, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
212
249
  return new Promise((resolve, reject) => {
213
250
  const { hostname, pathname, search } = new URL(url);
214
251
  const authHeader = "Basic " + Buffer.from(`:${pat}`).toString("base64");
@@ -223,7 +260,8 @@ function makeRequest(url, pat, method = "GET", body) {
223
260
  hostname,
224
261
  path: pathname + search,
225
262
  method,
226
- headers
263
+ headers,
264
+ timeout: timeoutMs
227
265
  };
228
266
  const req = https.request(options, (res) => {
229
267
  let data = "";
@@ -243,6 +281,10 @@ function makeRequest(url, pat, method = "GET", body) {
243
281
  }
244
282
  });
245
283
  });
284
+ req.on("timeout", () => {
285
+ req.destroy();
286
+ reject(new Error(`ADO request timed out after ${timeoutMs}ms: ${method} ${url}`));
287
+ });
246
288
  req.on("error", (error) => {
247
289
  reject(error);
248
290
  });
@@ -81,6 +81,14 @@ export async function buildHierarchicalWIQL(
81
81
  }
82
82
  }
83
83
 
84
+ // Per-container filters (each container applies its own filters)
85
+ if (container.filters) {
86
+ const containerFilterClauses = buildFilterClauses(container.filters);
87
+ if (containerFilterClauses.length > 0) {
88
+ parts.push(...containerFilterClauses);
89
+ }
90
+ }
91
+
84
92
  // Combine parts with AND
85
93
  projectClauses.push(`(${parts.join(' AND ')})`);
86
94
  }
@@ -88,15 +96,6 @@ export async function buildHierarchicalWIQL(
88
96
  // Build WHERE clause with project clauses
89
97
  let whereClause = projectClauses.join(' OR ');
90
98
 
91
- // Add global filters (apply to all projects)
92
- const filters = containers[0]?.filters;
93
- if (filters) {
94
- const filterClauses = buildFilterClauses(filters);
95
- if (filterClauses.length > 0) {
96
- whereClause = `(${whereClause}) AND ${filterClauses.join(' AND ')}`;
97
- }
98
- }
99
-
100
99
  // Build complete WIQL query
101
100
  return `
102
101
  SELECT [System.Id], [System.Title], [System.Description], [System.State],
@@ -185,15 +184,29 @@ function addTimeRangeFilter(wiql: string, timeRange: string): string {
185
184
 
186
185
  const { since, until } = calculateTimeRange(timeRange as TimeRangePreset);
187
186
 
188
- // Add time range to WHERE clause
189
187
  const timeFilter = `[System.CreatedDate] >= '${since}' AND [System.CreatedDate] <= '${until}'`;
190
188
 
191
- // Insert before ORDER BY if present, otherwise append
192
- if (wiql.includes('ORDER BY')) {
193
- return wiql.replace('ORDER BY', `AND ${timeFilter} ORDER BY`);
194
- } else {
189
+ // Find the last ORDER BY outside of string literals (case-insensitive)
190
+ // Strategy: find ORDER BY keyword, ensuring it's not inside single quotes
191
+ const orderByRegex = /\bORDER\s+BY\b/i;
192
+ const match = wiql.match(orderByRegex);
193
+
194
+ if (match && match.index !== undefined) {
195
+ // Insert AND timeFilter before ORDER BY
196
+ const before = wiql.substring(0, match.index).trimEnd();
197
+ const after = wiql.substring(match.index);
198
+ return `${before} AND ${timeFilter} ${after}`;
199
+ }
200
+
201
+ // No ORDER BY — check if there's a WHERE clause
202
+ const whereRegex = /\bWHERE\b/i;
203
+ if (whereRegex.test(wiql)) {
204
+ // Append to existing WHERE clause
195
205
  return `${wiql} AND ${timeFilter}`;
196
206
  }
207
+
208
+ // No WHERE clause at all — add one
209
+ return `${wiql} WHERE ${timeFilter}`;
197
210
  }
198
211
 
199
212
  /**
@@ -364,9 +377,15 @@ async function fetchWorkItemsFiltered(
364
377
 
365
378
  console.log('🔍 Fetching work items (FILTERED strategy):', wiql);
366
379
 
367
- // Use first project for API endpoint (WIQL can query across projects)
368
- const project = containers[0].id;
380
+ // Detect multi-project: if containers span more than one project, use org-level endpoint
381
+ const uniqueProjects = new Set(containers.map(c => c.id));
382
+ if (uniqueProjects.size > 1) {
383
+ // Cross-project: use org-level WIQL endpoint
384
+ return executeQueryOrgLevel(organization, pat, wiql);
385
+ }
369
386
 
387
+ // Single-project: use project-scoped endpoint
388
+ const project = containers[0].id;
370
389
  return executeQuery(organization, project, pat, wiql);
371
390
  }
372
391
 
@@ -419,13 +438,58 @@ async function executeQuery(
419
438
  }
420
439
 
421
440
  /**
422
- * Make HTTPS request to ADO API
441
+ * Execute WIQL query at org level (cross-project)
442
+ */
443
+ async function executeQueryOrgLevel(
444
+ organization: string,
445
+ pat: string,
446
+ wiql: string
447
+ ): Promise<WorkItem[]> {
448
+ const baseUrl = `https://dev.azure.com/${organization}`;
449
+
450
+ // Execute query at org level
451
+ const queryUrl = `${baseUrl}/_apis/wit/wiql?api-version=7.1`;
452
+ const queryResult: any = await makeRequest(queryUrl, pat, 'POST', { query: wiql });
453
+
454
+ if (!queryResult.workItems || queryResult.workItems.length === 0) {
455
+ return [];
456
+ }
457
+
458
+ // Get full work item details (batch request at org level)
459
+ const ids = queryResult.workItems.map((wi: any) => wi.id);
460
+ const batchUrl = `${baseUrl}/_apis/wit/workitemsbatch?api-version=7.1`;
461
+
462
+ const response: any = await makeRequest(batchUrl, pat, 'POST', {
463
+ ids,
464
+ fields: [
465
+ 'System.Id',
466
+ 'System.Title',
467
+ 'System.Description',
468
+ 'System.State',
469
+ 'System.CreatedDate',
470
+ 'System.ChangedDate',
471
+ 'System.WorkItemType',
472
+ 'System.AreaPath',
473
+ 'System.IterationPath',
474
+ 'System.Tags',
475
+ ],
476
+ });
477
+
478
+ return response.value || [];
479
+ }
480
+
481
+ /** Default request timeout in ms (configurable via ado.requestTimeoutMs) */
482
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
483
+
484
+ /**
485
+ * Make HTTPS request to ADO API with timeout
423
486
  */
424
487
  function makeRequest(
425
488
  url: string,
426
489
  pat: string,
427
490
  method: string = 'GET',
428
- body?: any
491
+ body?: any,
492
+ timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS
429
493
  ): Promise<any> {
430
494
  return new Promise((resolve, reject) => {
431
495
  const { hostname, pathname, search } = new URL(url);
@@ -446,6 +510,7 @@ function makeRequest(
446
510
  path: pathname + search,
447
511
  method,
448
512
  headers,
513
+ timeout: timeoutMs,
449
514
  };
450
515
 
451
516
  const req = https.request(options, (res) => {
@@ -470,6 +535,11 @@ function makeRequest(
470
535
  });
471
536
  });
472
537
 
538
+ req.on('timeout', () => {
539
+ req.destroy();
540
+ reject(new Error(`ADO request timed out after ${timeoutMs}ms: ${method} ${url}`));
541
+ });
542
+
473
543
  req.on('error', (error) => {
474
544
  reject(error);
475
545
  });
@@ -26,7 +26,7 @@ class AdoProfileResolver {
26
26
  };
27
27
  }
28
28
  const incrementProfile = await this.getIncrementProfile(incrementId);
29
- const globalDefaultProfile = config.sync?.defaultProfile;
29
+ const globalDefaultProfile = config.sync?.activeProfile ?? config.sync?.defaultProfile;
30
30
  const profileName = incrementProfile || globalDefaultProfile;
31
31
  if (!profileName) {
32
32
  return {
@@ -108,6 +108,7 @@ interface ConfigProfile {
108
108
  * Sync config from config.json
109
109
  */
110
110
  interface SyncConfig {
111
+ activeProfile?: string;
111
112
  defaultProfile?: string;
112
113
  profiles?: Record<string, ConfigProfile>;
113
114
  }
@@ -158,7 +159,8 @@ export class AdoProfileResolver {
158
159
  const incrementProfile = await this.getIncrementProfile(incrementId);
159
160
 
160
161
  // Determine which profile to use
161
- const globalDefaultProfile = config.sync?.defaultProfile;
162
+ // Read both activeProfile (canonical) and defaultProfile (legacy) with fallback
163
+ const globalDefaultProfile = config.sync?.activeProfile ?? config.sync?.defaultProfile;
162
164
  const profileName = incrementProfile || globalDefaultProfile;
163
165
 
164
166
  if (!profileName) {
@@ -163,7 +163,8 @@ class AdoSpecSync {
163
163
  value: this.mapPriorityToAdo(spec.metadata.priority)
164
164
  }
165
165
  ];
166
- const response = await this.client.post("/wit/workitems/$Feature?api-version=7.0", payload);
166
+ const encodedType = encodeURIComponent(workItemType);
167
+ const response = await this.client.post(`/wit/workitems/$${encodedType}?api-version=7.0`, payload);
167
168
  const featureData = response.data;
168
169
  console.log(` \u2705 Created ADO Feature #${featureData.id}: ${featureData._links.html.href}`);
169
170
  return {
@@ -173,29 +174,41 @@ class AdoSpecSync {
173
174
  };
174
175
  }
175
176
  /**
176
- * Update existing ADO Feature
177
+ * Update existing ADO Feature (conditional — only writes changed fields)
177
178
  */
178
179
  async updateAdoFeature(featureId, spec) {
179
180
  const featureTitle = `[${spec.metadata.id.toUpperCase()}] ${spec.metadata.title}`;
180
181
  const featureDescription = this.generateFeatureDescription(spec);
181
- const payload = [
182
- {
182
+ const current = await this.fetchAdoFeature(featureId);
183
+ const payload = [];
184
+ if (current.fields["System.Title"] !== featureTitle) {
185
+ payload.push({
183
186
  op: "replace",
184
187
  path: "/fields/System.Title",
185
188
  value: featureTitle
186
- },
187
- {
189
+ });
190
+ } else {
191
+ console.log(` \u2139\uFE0F Title unchanged, skipping`);
192
+ }
193
+ if (current.fields["System.Description"] !== featureDescription) {
194
+ payload.push({
188
195
  op: "replace",
189
196
  path: "/fields/System.Description",
190
197
  value: featureDescription
191
- }
192
- ];
198
+ });
199
+ } else {
200
+ console.log(` \u2139\uFE0F Description unchanged, skipping`);
201
+ }
202
+ if (payload.length === 0) {
203
+ console.log(` \u2139\uFE0F No changes detected for ADO Feature #${featureId}`);
204
+ return current;
205
+ }
193
206
  const response = await this.client.patch(
194
207
  `/wit/workitems/${featureId}?api-version=7.0`,
195
208
  payload
196
209
  );
197
210
  const featureData = response.data;
198
- console.log(` \u2705 Updated ADO Feature #${featureId}`);
211
+ console.log(` \u2705 Updated ADO Feature #${featureId} (${payload.length} field(s) changed)`);
199
212
  return {
200
213
  id: featureData.id,
201
214
  url: featureData._links.html.href,
@@ -255,7 +255,8 @@ export class AdoSpecSync {
255
255
  }
256
256
  ];
257
257
 
258
- const response = await this.client.post('/wit/workitems/$Feature?api-version=7.0', payload);
258
+ const encodedType = encodeURIComponent(workItemType);
259
+ const response = await this.client.post(`/wit/workitems/$${encodedType}?api-version=7.0`, payload);
259
260
  const featureData = response.data;
260
261
 
261
262
  console.log(` ✅ Created ADO Feature #${featureData.id}: ${featureData._links.html.href}`);
@@ -268,24 +269,41 @@ export class AdoSpecSync {
268
269
  }
269
270
 
270
271
  /**
271
- * Update existing ADO Feature
272
+ * Update existing ADO Feature (conditional — only writes changed fields)
272
273
  */
273
274
  private async updateAdoFeature(featureId: number, spec: SpecContent): Promise<AdoFeature> {
274
275
  const featureTitle = `[${spec.metadata.id.toUpperCase()}] ${spec.metadata.title}`;
275
276
  const featureDescription = this.generateFeatureDescription(spec);
276
277
 
277
- const payload = [
278
- {
278
+ // Fetch current values to avoid overwriting ADO-side edits
279
+ const current = await this.fetchAdoFeature(featureId);
280
+
281
+ const payload: any[] = [];
282
+
283
+ if (current.fields['System.Title'] !== featureTitle) {
284
+ payload.push({
279
285
  op: 'replace',
280
286
  path: '/fields/System.Title',
281
287
  value: featureTitle
282
- },
283
- {
288
+ });
289
+ } else {
290
+ console.log(` ℹ️ Title unchanged, skipping`);
291
+ }
292
+
293
+ if (current.fields['System.Description'] !== featureDescription) {
294
+ payload.push({
284
295
  op: 'replace',
285
296
  path: '/fields/System.Description',
286
297
  value: featureDescription
287
- }
288
- ];
298
+ });
299
+ } else {
300
+ console.log(` ℹ️ Description unchanged, skipping`);
301
+ }
302
+
303
+ if (payload.length === 0) {
304
+ console.log(` ℹ️ No changes detected for ADO Feature #${featureId}`);
305
+ return current;
306
+ }
289
307
 
290
308
  const response = await this.client.patch(
291
309
  `/wit/workitems/${featureId}?api-version=7.0`,
@@ -293,7 +311,7 @@ export class AdoSpecSync {
293
311
  );
294
312
  const featureData = response.data;
295
313
 
296
- console.log(` ✅ Updated ADO Feature #${featureId}`);
314
+ console.log(` ✅ Updated ADO Feature #${featureId} (${payload.length} field(s) changed)`);
297
315
 
298
316
  return {
299
317
  id: featureData.id,
@@ -3,6 +3,7 @@ import * as path from "path";
3
3
  import * as yaml from "yaml";
4
4
  const STATUS_MAPPING = {
5
5
  ado: {
6
+ // Agile process template
6
7
  "New": "draft",
7
8
  "Active": "in-progress",
8
9
  "Resolved": "implemented",
@@ -10,7 +11,20 @@ const STATUS_MAPPING = {
10
11
  "In Review": "in-qa",
11
12
  "In QA": "in-qa",
12
13
  "Blocked": "blocked",
13
- "Removed": "cancelled"
14
+ "Removed": "cancelled",
15
+ // Scrum process template
16
+ "Approved": "draft",
17
+ "Committed": "in-progress",
18
+ "Done": "complete",
19
+ // CMMI process template
20
+ "Proposed": "draft",
21
+ // 'Active' already mapped above (shared with CMMI)
22
+ // 'Resolved' already mapped above (shared with CMMI)
23
+ // 'Closed' already mapped above (shared with CMMI)
24
+ // Basic process template
25
+ "To Do": "draft",
26
+ "Doing": "in-progress"
27
+ // 'Done' already mapped above (shared with Basic)
14
28
  },
15
29
  jira: {
16
30
  "To Do": "draft",
@@ -30,6 +44,8 @@ const STATUS_MAPPING = {
30
44
  }
31
45
  };
32
46
  const REVERSE_STATUS_MAPPING = {
47
+ // Default reverse mapping uses Agile states (most common)
48
+ // Callers should use process-template-aware mapping when template is known
33
49
  ado: {
34
50
  "draft": "New",
35
51
  "in-progress": "Active",
@@ -87,6 +87,7 @@ export interface ExternalStatus {
87
87
 
88
88
  const STATUS_MAPPING = {
89
89
  ado: {
90
+ // Agile process template
90
91
  'New': 'draft' as SpecStatus,
91
92
  'Active': 'in-progress' as SpecStatus,
92
93
  'Resolved': 'implemented' as SpecStatus,
@@ -94,7 +95,20 @@ const STATUS_MAPPING = {
94
95
  'In Review': 'in-qa' as SpecStatus,
95
96
  'In QA': 'in-qa' as SpecStatus,
96
97
  'Blocked': 'blocked' as SpecStatus,
97
- 'Removed': 'cancelled' as SpecStatus
98
+ 'Removed': 'cancelled' as SpecStatus,
99
+ // Scrum process template
100
+ 'Approved': 'draft' as SpecStatus,
101
+ 'Committed': 'in-progress' as SpecStatus,
102
+ 'Done': 'complete' as SpecStatus,
103
+ // CMMI process template
104
+ 'Proposed': 'draft' as SpecStatus,
105
+ // 'Active' already mapped above (shared with CMMI)
106
+ // 'Resolved' already mapped above (shared with CMMI)
107
+ // 'Closed' already mapped above (shared with CMMI)
108
+ // Basic process template
109
+ 'To Do': 'draft' as SpecStatus,
110
+ 'Doing': 'in-progress' as SpecStatus,
111
+ // 'Done' already mapped above (shared with Basic)
98
112
  },
99
113
  jira: {
100
114
  'To Do': 'draft' as SpecStatus,
@@ -115,6 +129,8 @@ const STATUS_MAPPING = {
115
129
  };
116
130
 
117
131
  const REVERSE_STATUS_MAPPING = {
132
+ // Default reverse mapping uses Agile states (most common)
133
+ // Callers should use process-template-aware mapping when template is known
118
134
  ado: {
119
135
  'draft': 'New',
120
136
  'in-progress': 'Active',
@@ -130,10 +130,20 @@ function buildTaskMapping(increments, organization, project) {
130
130
  title: task.title,
131
131
  userStories: task.userStories
132
132
  }));
133
+ // Derive repository name from git remote or fall back to project name
134
+ let repoName = project;
135
+ try {
136
+ const { execSync } = require("child_process");
137
+ const remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
138
+ const match = remoteUrl.match(/\/([^/]+?)(?:\.git)?$/);
139
+ if (match) repoName = match[1];
140
+ } catch {
141
+ // Fallback to project name if git is unavailable
142
+ }
133
143
  return {
134
144
  incrementId: firstIncrement.id,
135
145
  tasks,
136
- tasksUrl: `https://dev.azure.com/${organization}/${project}/_git/repo?path=/.specweave/increments/${firstIncrement.id}/tasks.md`
146
+ tasksUrl: `https://dev.azure.com/${organization}/${project}/_git/${repoName}?path=/.specweave/increments/${firstIncrement.id}/tasks.md`
137
147
  };
138
148
  }
139
149
  async function findArchitectureDocs(rootDir, specId) {
@@ -1,9 +1,16 @@
1
1
  import { consoleLogger } from "../../../src/utils/logger.js";
2
+ const WORK_ITEM_TYPE_BY_TEMPLATE = {
3
+ agile: "User Story",
4
+ scrum: "Product Backlog Item",
5
+ cmmi: "Requirement",
6
+ basic: "Issue"
7
+ };
2
8
  class PerUSAdoSync {
3
9
  constructor(adoClient, projectMappings, options = {}) {
4
10
  this.adoClient = adoClient;
5
11
  this.projectMappings = projectMappings;
6
12
  this.logger = options.logger ?? consoleLogger;
13
+ this.workItemType = options.workItemType ?? WORK_ITEM_TYPE_BY_TEMPLATE[options.processTemplate?.toLowerCase() ?? ""] ?? "User Story";
7
14
  }
8
15
  /**
9
16
  * Sync all user stories to their respective ADO projects
@@ -133,7 +140,7 @@ class PerUSAdoSync {
133
140
  } else {
134
141
  const newItem = await this.adoClient.createWorkItem(
135
142
  mapping.project,
136
- "User Story",
143
+ this.workItemType,
137
144
  title,
138
145
  description,
139
146
  areaPath
@@ -81,6 +81,16 @@ export interface AdoClient {
81
81
  getWorkItemUrl(project: string, workItemId: number): string;
82
82
  }
83
83
 
84
+ /**
85
+ * Default work item type by process template
86
+ */
87
+ const WORK_ITEM_TYPE_BY_TEMPLATE: Record<string, string> = {
88
+ agile: 'User Story',
89
+ scrum: 'Product Backlog Item',
90
+ cmmi: 'Requirement',
91
+ basic: 'Issue',
92
+ };
93
+
84
94
  /**
85
95
  * Per-US ADO Sync
86
96
  *
@@ -91,15 +101,20 @@ export class PerUSAdoSync {
91
101
  private projectMappings: ProjectMappings;
92
102
  private adoClient: AdoClient;
93
103
  private logger: Logger;
104
+ private workItemType: string;
94
105
 
95
106
  constructor(
96
107
  adoClient: AdoClient,
97
108
  projectMappings: ProjectMappings,
98
- options: { logger?: Logger } = {}
109
+ options: { logger?: Logger; workItemType?: string; processTemplate?: string } = {}
99
110
  ) {
100
111
  this.adoClient = adoClient;
101
112
  this.projectMappings = projectMappings;
102
113
  this.logger = options.logger ?? consoleLogger;
114
+ // Explicit workItemType takes priority, then process template lookup, then default
115
+ this.workItemType = options.workItemType
116
+ ?? WORK_ITEM_TYPE_BY_TEMPLATE[options.processTemplate?.toLowerCase() ?? '']
117
+ ?? 'User Story';
103
118
  }
104
119
 
105
120
  /**
@@ -268,7 +283,7 @@ export class PerUSAdoSync {
268
283
  // Create new work item
269
284
  const newItem = await this.adoClient.createWorkItem(
270
285
  mapping.project,
271
- 'User Story',
286
+ this.workItemType,
272
287
  title,
273
288
  description,
274
289
  areaPath
@@ -74,14 +74,38 @@ else
74
74
  [[ "$GH_ENABLED" != "true" ]] && { log "GitHub sync not enabled"; exit 0; }
75
75
  fi
76
76
 
77
- # Check auto-create enabled (either autoSync OR auto_create_github_issue)
77
+ # Check auto-create enabled
78
+ # Two flags control auto-creation behavior:
79
+ # - sync.autoSync (boolean): Global auto-sync toggle. When true, enables ALL auto-sync
80
+ # operations including issue creation, progress sync, and status updates.
81
+ # - hooks.post_increment_planning.auto_create_github_issue (boolean): Fine-grained toggle
82
+ # for ONLY the auto-create-issue behavior in post-increment-planning hooks.
83
+ #
84
+ # Precedence: auto_create_github_issue (specific) > autoSync (global)
85
+ # If BOTH are set, auto_create_github_issue takes precedence for issue creation.
86
+ # If ONLY autoSync is true, issue creation is enabled (as part of the global auto-sync).
78
87
  AUTO_SYNC=$(jq -r '.sync.autoSync // false' "$CONFIG_PATH" 2>/dev/null)
79
88
  AUTO_CREATE=$(jq -r '.hooks.post_increment_planning.auto_create_github_issue // false' "$CONFIG_PATH" 2>/dev/null)
89
+
90
+ # Warn if only one flag is set (likely misconfiguration)
91
+ if [[ "$AUTO_SYNC" == "true" ]] && [[ "$AUTO_CREATE" == "false" ]]; then
92
+ log "Warning: sync.autoSync=true but auto_create_github_issue=false. Issue creation enabled via autoSync. Set auto_create_github_issue=true to be explicit."
93
+ elif [[ "$AUTO_SYNC" == "false" ]] && [[ "$AUTO_CREATE" == "true" ]]; then
94
+ log "Warning: auto_create_github_issue=true but sync.autoSync=false. Only issue creation is enabled; other sync operations are disabled."
95
+ fi
96
+
80
97
  if [[ "$AUTO_SYNC" != "true" ]] && [[ "$AUTO_CREATE" != "true" ]]; then
81
98
  log "Auto-sync disabled (autoSync=$AUTO_SYNC, auto_create=$AUTO_CREATE)"
82
99
  exit 0
83
100
  fi
84
101
 
102
+ # Log which flag triggered activation
103
+ if [[ "$AUTO_CREATE" == "true" ]]; then
104
+ log "Auto-create enabled via hooks.post_increment_planning.auto_create_github_issue"
105
+ else
106
+ log "Auto-create enabled via sync.autoSync (global toggle)"
107
+ fi
108
+
85
109
  # Get owner/repo
86
110
  OWNER=$(jq -r '.sync.github.owner // ""' "$CONFIG_PATH" 2>/dev/null)
87
111
  REPO=$(jq -r '.sync.github.repo // ""' "$CONFIG_PATH" 2>/dev/null)
@@ -115,7 +139,9 @@ fi
115
139
  if [[ "${SPECWEAVE_SKIP_DEBOUNCE:-0}" != "1" ]]; then
116
140
  DEBOUNCE_FILE="$STATE_DIR/.github-auto-create-pending-$INC_ID"
117
141
  if [[ -f "$DEBOUNCE_FILE" ]]; then
118
- SIGNAL_AGE=$(($(date +%s) - $(stat -f "%m" "$DEBOUNCE_FILE" 2>/dev/null || echo 0)))
142
+ # POSIX-portable: use perl for mtime (works on macOS and Linux)
143
+ FILE_MTIME=$(perl -e 'print((stat($ARGV[0]))[9])' "$DEBOUNCE_FILE" 2>/dev/null || echo 0)
144
+ SIGNAL_AGE=$(($(date +%s) - FILE_MTIME))
119
145
  if (( SIGNAL_AGE < 30 )); then
120
146
  log "Debounce: signal age ${SIGNAL_AGE}s < 30s. Deferring."
121
147
  exit 0
@@ -80,10 +80,13 @@ for i in {1..15}; do
80
80
  break
81
81
  fi
82
82
 
83
- # Check for stale lock
83
+ # Check for stale lock (POSIX-portable: works on macOS and Linux)
84
84
  if [[ -d "$LOCK_FILE" ]]; then
85
- LOCK_AGE=$(($(date +%s) - $(stat -f "%m" "$LOCK_FILE" 2>/dev/null || echo 0)))
86
- if (( LOCK_AGE > LOCK_TIMEOUT )); then
85
+ # Use find -mmin which works on both macOS and Linux
86
+ LOCK_TIMEOUT_MIN=$(( (LOCK_TIMEOUT + 59) / 60 )) # Convert seconds to minutes (ceil)
87
+ [[ $LOCK_TIMEOUT_MIN -lt 1 ]] && LOCK_TIMEOUT_MIN=1
88
+ STALE=$(find "$LOCK_FILE" -maxdepth 0 -mmin +${LOCK_TIMEOUT_MIN} 2>/dev/null)
89
+ if [[ -n "$STALE" ]]; then
87
90
  rmdir "$LOCK_FILE" 2>/dev/null || true
88
91
  continue
89
92
  fi