specweave 1.0.520 → 1.0.522

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 (129) hide show
  1. package/.claude-plugin/README.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/README.md +1 -1
  4. package/bin/specweave.js +8 -8
  5. package/dist/plugins/specweave/lib/integrations/github/duplicate-detector.js +6 -6
  6. package/dist/plugins/specweave/lib/integrations/github/duplicate-detector.js.map +1 -1
  7. package/dist/plugins/specweave/lib/integrations/github/github-ac-comment-poster.d.ts.map +1 -1
  8. package/dist/plugins/specweave/lib/integrations/github/github-ac-comment-poster.js +34 -9
  9. package/dist/plugins/specweave/lib/integrations/github/github-ac-comment-poster.js.map +1 -1
  10. package/dist/plugins/specweave/lib/integrations/github/github-body-utils.d.ts +38 -0
  11. package/dist/plugins/specweave/lib/integrations/github/github-body-utils.d.ts.map +1 -0
  12. package/dist/plugins/specweave/lib/integrations/github/github-body-utils.js +50 -0
  13. package/dist/plugins/specweave/lib/integrations/github/github-body-utils.js.map +1 -0
  14. package/dist/plugins/specweave/lib/integrations/github/github-feature-sync.d.ts +0 -2
  15. package/dist/plugins/specweave/lib/integrations/github/github-feature-sync.d.ts.map +1 -1
  16. package/dist/plugins/specweave/lib/integrations/github/github-feature-sync.js +194 -173
  17. package/dist/plugins/specweave/lib/integrations/github/github-feature-sync.js.map +1 -1
  18. package/dist/src/adapters/codex/README.md +1 -1
  19. package/dist/src/adapters/gemini/README.md +1 -1
  20. package/dist/src/cli/commands/complete.d.ts +10 -7
  21. package/dist/src/cli/commands/complete.d.ts.map +1 -1
  22. package/dist/src/cli/commands/complete.js +60 -14
  23. package/dist/src/cli/commands/complete.js.map +1 -1
  24. package/dist/src/cli/commands/create-increment.d.ts.map +1 -1
  25. package/dist/src/cli/commands/create-increment.js +2 -10
  26. package/dist/src/cli/commands/create-increment.js.map +1 -1
  27. package/dist/src/cli/commands/evaluate-completion.d.ts.map +1 -1
  28. package/dist/src/cli/commands/evaluate-completion.js +8 -38
  29. package/dist/src/cli/commands/evaluate-completion.js.map +1 -1
  30. package/dist/src/cli/commands/init.d.ts.map +1 -1
  31. package/dist/src/cli/commands/init.js +25 -0
  32. package/dist/src/cli/commands/init.js.map +1 -1
  33. package/dist/src/cli/commands/save.js +1 -1
  34. package/dist/src/cli/commands/save.js.map +1 -1
  35. package/dist/src/cli/commands/sync-progress.d.ts.map +1 -1
  36. package/dist/src/cli/commands/sync-progress.js +10 -0
  37. package/dist/src/cli/commands/sync-progress.js.map +1 -1
  38. package/dist/src/cli/helpers/init/claude-settings-lsp.js +1 -1
  39. package/dist/src/cli/helpers/init/claude-settings-lsp.js.map +1 -1
  40. package/dist/src/cli/helpers/issue-tracker/github-multi-repo.d.ts +1 -8
  41. package/dist/src/cli/helpers/issue-tracker/github-multi-repo.d.ts.map +1 -1
  42. package/dist/src/cli/helpers/issue-tracker/github-multi-repo.js +57 -88
  43. package/dist/src/cli/helpers/issue-tracker/github-multi-repo.js.map +1 -1
  44. package/dist/src/cli/helpers/issue-tracker/github.d.ts.map +1 -1
  45. package/dist/src/cli/helpers/issue-tracker/github.js +1 -3
  46. package/dist/src/cli/helpers/issue-tracker/github.js.map +1 -1
  47. package/dist/src/core/increment/completion-validator.d.ts.map +1 -1
  48. package/dist/src/core/increment/completion-validator.js +2 -3
  49. package/dist/src/core/increment/completion-validator.js.map +1 -1
  50. package/dist/src/core/increment/increment-utils.d.ts +7 -7
  51. package/dist/src/core/increment/increment-utils.d.ts.map +1 -1
  52. package/dist/src/core/increment/increment-utils.js +19 -13
  53. package/dist/src/core/increment/increment-utils.js.map +1 -1
  54. package/dist/src/core/increment/status-change-sync-trigger.d.ts.map +1 -1
  55. package/dist/src/core/increment/status-change-sync-trigger.js +9 -0
  56. package/dist/src/core/increment/status-change-sync-trigger.js.map +1 -1
  57. package/dist/src/core/repo-structure/prompt-consolidator.d.ts +3 -17
  58. package/dist/src/core/repo-structure/prompt-consolidator.d.ts.map +1 -1
  59. package/dist/src/core/repo-structure/prompt-consolidator.js +1 -55
  60. package/dist/src/core/repo-structure/prompt-consolidator.js.map +1 -1
  61. package/dist/src/core/repo-structure/repo-structure-manager.d.ts.map +1 -1
  62. package/dist/src/core/repo-structure/repo-structure-manager.js +3 -1
  63. package/dist/src/core/repo-structure/repo-structure-manager.js.map +1 -1
  64. package/dist/src/core/repo-structure/setup-state-manager.d.ts.map +1 -1
  65. package/dist/src/core/repo-structure/setup-state-manager.js +2 -1
  66. package/dist/src/core/repo-structure/setup-state-manager.js.map +1 -1
  67. package/dist/src/core/repo-structure/setup-summary.js +1 -1
  68. package/dist/src/core/repo-structure/setup-summary.js.map +1 -1
  69. package/dist/src/core/sync-throttle.d.ts +49 -0
  70. package/dist/src/core/sync-throttle.d.ts.map +1 -0
  71. package/dist/src/core/sync-throttle.js +94 -0
  72. package/dist/src/core/sync-throttle.js.map +1 -0
  73. package/dist/src/dashboard/server/dashboard-server.js +1 -1
  74. package/dist/src/dashboard/server/dashboard-server.js.map +1 -1
  75. package/dist/src/hooks/auto-create-external-issue.js +11 -0
  76. package/dist/src/hooks/auto-create-external-issue.js.map +1 -1
  77. package/dist/src/init/InitFlow.d.ts.map +1 -1
  78. package/dist/src/init/InitFlow.js +4 -8
  79. package/dist/src/init/InitFlow.js.map +1 -1
  80. package/dist/src/init/research/src/config/ConfigManager.js +1 -1
  81. package/dist/src/init/research/src/config/ConfigManager.js.map +1 -1
  82. package/dist/src/init/research/src/config/types.d.ts +0 -1
  83. package/dist/src/init/research/src/config/types.d.ts.map +1 -1
  84. package/dist/src/init/research/src/config/types.js +1 -1
  85. package/dist/src/init/research/src/config/types.js.map +1 -1
  86. package/dist/src/locales/de/cli.json +1 -1
  87. package/dist/src/locales/en/cli.json +1 -1
  88. package/dist/src/locales/es/cli.json +1 -1
  89. package/dist/src/locales/fr/cli.json +1 -1
  90. package/dist/src/locales/ja/cli.json +1 -1
  91. package/dist/src/locales/ko/cli.json +1 -1
  92. package/dist/src/locales/pt/cli.json +1 -1
  93. package/dist/src/locales/ru/cli.json +1 -1
  94. package/dist/src/locales/zh/cli.json +1 -1
  95. package/dist/src/utils/resolve-increment-id.d.ts +24 -0
  96. package/dist/src/utils/resolve-increment-id.d.ts.map +1 -0
  97. package/dist/src/utils/resolve-increment-id.js +53 -0
  98. package/dist/src/utils/resolve-increment-id.js.map +1 -0
  99. package/package.json +1 -1
  100. package/plugins/specweave/.claude-plugin/plugin.json +2 -2
  101. package/plugins/specweave/commands/import-external.md +4 -4
  102. package/plugins/specweave/lib/integrations/github/duplicate-detector.js +1 -1
  103. package/plugins/specweave/lib/integrations/github/duplicate-detector.ts +6 -6
  104. package/plugins/specweave/lib/integrations/github/github-ac-comment-poster.js +39 -13
  105. package/plugins/specweave/lib/integrations/github/github-ac-comment-poster.ts +43 -13
  106. package/plugins/specweave/lib/integrations/github/github-body-utils.js +15 -0
  107. package/plugins/specweave/lib/integrations/github/github-body-utils.ts +52 -0
  108. package/plugins/specweave/lib/integrations/github/github-feature-sync.js +160 -136
  109. package/plugins/specweave/lib/integrations/github/github-feature-sync.ts +49 -29
  110. package/plugins/specweave/skills/increment/SKILL.md +45 -88
  111. package/plugins/specweave/skills/team-lead/SKILL.md +68 -4
  112. package/scripts/check-node-version.js +1 -1
  113. package/src/templates/lsp-plugin/plugin.json +1 -1
  114. package/dist/src/core/migration/consolidation-engine.d.ts +0 -59
  115. package/dist/src/core/migration/consolidation-engine.d.ts.map +0 -1
  116. package/dist/src/core/migration/consolidation-engine.js +0 -177
  117. package/dist/src/core/migration/consolidation-engine.js.map +0 -1
  118. package/dist/src/core/migration/spec-project-mapper.d.ts +0 -51
  119. package/dist/src/core/migration/spec-project-mapper.d.ts.map +0 -1
  120. package/dist/src/core/migration/spec-project-mapper.js +0 -299
  121. package/dist/src/core/migration/spec-project-mapper.js.map +0 -1
  122. package/dist/src/core/migration/types.d.ts +0 -132
  123. package/dist/src/core/migration/types.d.ts.map +0 -1
  124. package/dist/src/core/migration/types.js +0 -10
  125. package/dist/src/core/migration/types.js.map +0 -1
  126. package/dist/src/core/migration/umbrella-migrator.d.ts +0 -58
  127. package/dist/src/core/migration/umbrella-migrator.d.ts.map +0 -1
  128. package/dist/src/core/migration/umbrella-migrator.js +0 -617
  129. package/dist/src/core/migration/umbrella-migrator.js.map +0 -1
@@ -7,8 +7,9 @@ import { CompletionCalculator } from "./completion-calculator.js";
7
7
  import { DuplicateDetector } from "./duplicate-detector.js";
8
8
  import { execFileNoThrow } from "../../vendor/utils/execFileNoThrow.js";
9
9
  import { getGitHubAuthFromProject } from "../../vendor/utils/auth-helpers.js";
10
- const _GitHubFeatureSync = class _GitHubFeatureSync {
11
- // 30 seconds
10
+ import { LockManager } from "../../../../../src/utils/lock-manager.js";
11
+ import { normalizeIssueBody } from "./github-body-utils.js";
12
+ class GitHubFeatureSync {
12
13
  constructor(client, specsDir, projectRoot) {
13
14
  // Cached default branch for the sync session (one API call per session)
14
15
  this.defaultBranch = null;
@@ -69,14 +70,14 @@ const _GitHubFeatureSync = class _GitHubFeatureSync {
69
70
  * 4. Update frontmatter with GitHub issue links
70
71
  */
71
72
  async syncFeatureToGitHub(featureId, projectName) {
72
- const lockKey = `${this.client.getOwner()}/${this.client.getRepo()}:${featureId}`;
73
- const now = Date.now();
74
- const lastSync = _GitHubFeatureSync.syncLocks.get(lockKey);
75
- if (lastSync && now - lastSync < _GitHubFeatureSync.LOCK_DURATION_MS) {
76
- const secondsRemaining = Math.ceil((_GitHubFeatureSync.LOCK_DURATION_MS - (now - lastSync)) / 1e3);
73
+ const owner = this.client.getOwner();
74
+ const repo = this.client.getRepo();
75
+ const lockDir = path.join(this.projectRoot, ".specweave", "state", "locks", `github-sync-${owner}-${repo}`);
76
+ const lock = new LockManager(lockDir, 120);
77
+ const acquired = await lock.acquire();
78
+ if (!acquired) {
77
79
  console.log(`
78
- \u23ED\uFE0F Sync already in progress for ${featureId} (or completed ${Math.floor((now - lastSync) / 1e3)}s ago)`);
79
- console.log(` \u2139\uFE0F Sync will be available in ${secondsRemaining}s to prevent duplicates`);
80
+ \u23ED\uFE0F Sync already in progress for ${featureId} (lock held by another process)`);
80
81
  console.log(` \u{1F4A1} This prevents race conditions between task completion and status change syncs`);
81
82
  return {
82
83
  milestoneNumber: 0,
@@ -86,123 +87,126 @@ const _GitHubFeatureSync = class _GitHubFeatureSync {
86
87
  userStoriesProcessed: 0
87
88
  };
88
89
  }
89
- _GitHubFeatureSync.syncLocks.set(lockKey, now);
90
- console.log(`
90
+ try {
91
+ console.log(`
91
92
  \u{1F504} Syncing Feature ${featureId} to GitHub...`);
92
- const featureFolder = await this.findFeatureFolder(featureId, projectName);
93
- if (!featureFolder) {
94
- console.log(` \u26A0\uFE0F Feature ${featureId} not found in ${this.specsDir} (no living docs and auto-create failed)`);
95
- console.log(` \u{1F4A1} Run /sw:sync-docs or /sw:living-docs to generate living docs first`);
96
- return {
97
- milestoneNumber: 0,
98
- milestoneUrl: "",
99
- issuesCreated: 0,
100
- issuesUpdated: 0,
101
- userStoriesProcessed: 0
102
- };
103
- }
104
- const featurePath = path.join(featureFolder, "FEATURE.md");
105
- const featureData = await this.parseFeatureMd(featurePath);
106
- console.log(` \u{1F4E6} Feature: ${featureData.title}`);
107
- console.log(` \u{1F4CA} Status: ${featureData.status}`);
108
- let milestoneNumber = featureData.external_tools?.github?.id;
109
- let milestoneUrl = featureData.external_tools?.github?.url;
110
- if (!milestoneNumber) {
111
- console.log(` \u{1F680} Creating GitHub Milestone...`);
112
- const milestone = await this.createMilestone(featureData);
113
- milestoneNumber = milestone.number;
114
- milestoneUrl = milestone.url;
115
- console.log(` \u2705 Created Milestone #${milestoneNumber}`);
116
- await this.updateFeatureMd(featurePath, {
117
- type: "milestone",
118
- id: milestoneNumber,
119
- url: milestoneUrl
120
- });
121
- } else {
122
- console.log(` \u267B\uFE0F Using existing Milestone #${milestoneNumber}`);
123
- milestoneUrl = featureData.external_tools?.github?.url || milestoneUrl;
124
- }
125
- const userStories = await this.findUserStories(featureId, projectName);
126
- console.log(`
127
- \u{1F4DD} Found ${userStories.length} User Stories to sync...`);
128
- let issuesCreated = 0;
129
- let issuesUpdated = 0;
130
- const detectedBranch = await this.detectDefaultBranch();
131
- console.log(` \u{1F33F} Default branch: ${detectedBranch}`);
132
- for (const userStory of userStories) {
93
+ const featureFolder = await this.findFeatureFolder(featureId, projectName);
94
+ if (!featureFolder) {
95
+ console.log(` \u26A0\uFE0F Feature ${featureId} not found in ${this.specsDir} (no living docs and auto-create failed)`);
96
+ console.log(` \u{1F4A1} Run /sw:sync-docs or /sw:living-docs to generate living docs first`);
97
+ return {
98
+ milestoneNumber: 0,
99
+ milestoneUrl: "",
100
+ issuesCreated: 0,
101
+ issuesUpdated: 0,
102
+ userStoriesProcessed: 0
103
+ };
104
+ }
105
+ const featurePath = path.join(featureFolder, "FEATURE.md");
106
+ const featureData = await this.parseFeatureMd(featurePath);
107
+ console.log(` \u{1F4E6} Feature: ${featureData.title}`);
108
+ console.log(` \u{1F4CA} Status: ${featureData.status}`);
109
+ let milestoneNumber = featureData.external_tools?.github?.id;
110
+ let milestoneUrl = featureData.external_tools?.github?.url;
111
+ if (!milestoneNumber) {
112
+ console.log(` \u{1F680} Creating GitHub Milestone...`);
113
+ const milestone = await this.createMilestone(featureData);
114
+ milestoneNumber = milestone.number;
115
+ milestoneUrl = milestone.url;
116
+ console.log(` \u2705 Created Milestone #${milestoneNumber}`);
117
+ await this.updateFeatureMd(featurePath, {
118
+ type: "milestone",
119
+ id: milestoneNumber,
120
+ url: milestoneUrl
121
+ });
122
+ } else {
123
+ console.log(` \u267B\uFE0F Using existing Milestone #${milestoneNumber}`);
124
+ milestoneUrl = featureData.external_tools?.github?.url || milestoneUrl;
125
+ }
126
+ const userStories = await this.findUserStories(featureId, projectName);
133
127
  console.log(`
128
+ \u{1F4DD} Found ${userStories.length} User Stories to sync...`);
129
+ let issuesCreated = 0;
130
+ let issuesUpdated = 0;
131
+ const detectedBranch = await this.detectDefaultBranch();
132
+ console.log(` \u{1F33F} Default branch: ${detectedBranch}`);
133
+ for (const userStory of userStories) {
134
+ console.log(`
134
135
  \u{1F539} Processing ${userStory.id}: ${userStory.title}`);
135
- const repoInfo = {
136
- owner: this.client.getOwner(),
137
- repo: this.client.getRepo(),
138
- branch: detectedBranch
139
- };
140
- const builder = new UserStoryIssueBuilder(
141
- userStory.filePath,
142
- this.projectRoot,
143
- featureId,
144
- repoInfo
145
- );
146
- const issueContent = await builder.buildIssueBody();
147
- issueContent.status = userStory.status;
148
- let issueNumber;
149
- let wasUpdated = false;
150
- if (userStory.existingIssue) {
151
- console.log(` \u267B\uFE0F Issue #${userStory.existingIssue} exists in frontmatter`);
152
- try {
153
- await this.client.getIssue(userStory.existingIssue);
154
- await this.updateUserStoryIssue(userStory.existingIssue, issueContent, userStory.filePath);
136
+ const repoInfo = {
137
+ owner: this.client.getOwner(),
138
+ repo: this.client.getRepo(),
139
+ branch: detectedBranch
140
+ };
141
+ const builder = new UserStoryIssueBuilder(
142
+ userStory.filePath,
143
+ this.projectRoot,
144
+ featureId,
145
+ repoInfo
146
+ );
147
+ const issueContent = await builder.buildIssueBody();
148
+ issueContent.status = userStory.status;
149
+ let issueNumber;
150
+ let wasUpdated = false;
151
+ if (userStory.existingIssue) {
152
+ console.log(` \u267B\uFE0F Issue #${userStory.existingIssue} exists in frontmatter`);
153
+ try {
154
+ await this.client.getIssue(userStory.existingIssue);
155
+ await this.updateUserStoryIssue(userStory.existingIssue, issueContent, userStory.filePath);
156
+ issuesUpdated++;
157
+ console.log(` \u2705 Updated Issue #${userStory.existingIssue}`);
158
+ continue;
159
+ } catch (err) {
160
+ console.log(` \u26A0\uFE0F Issue #${userStory.existingIssue} deleted on GitHub, creating new`);
161
+ }
162
+ }
163
+ const titlePattern = `[${featureId}][${userStory.id}]`;
164
+ const milestoneTitle = `${featureData.id}: ${featureData.title}`;
165
+ console.log(` \u{1F6E1}\uFE0F Using DuplicateDetector (pattern: ${titlePattern})`);
166
+ const result = await DuplicateDetector.createWithProtection({
167
+ title: issueContent.title,
168
+ body: issueContent.body,
169
+ titlePattern,
170
+ incrementId: userStory.id,
171
+ labels: issueContent.labels,
172
+ milestone: milestoneTitle,
173
+ repo: `${this.client.getOwner()}/${this.client.getRepo()}`
174
+ });
175
+ issueNumber = result.issue.number;
176
+ if (result.wasReused) {
177
+ console.log(` \u267B\uFE0F Reused existing issue #${issueNumber} (duplicate prevented!)`);
178
+ wasUpdated = true;
179
+ } else {
180
+ console.log(` \u2705 Created issue #${issueNumber}`);
181
+ }
182
+ if (result.duplicatesFound > 0) {
183
+ console.log(` \u{1F6E1}\uFE0F Duplicates detected: ${result.duplicatesFound}, auto-closed: ${result.duplicatesClosed}`);
184
+ }
185
+ await this.updateUserStoryFrontmatter(userStory.filePath, issueNumber);
186
+ await this.backfillIncrementMetadata(featureId, userStory.id, issueNumber, milestoneNumber);
187
+ await this.updateUserStoryIssue(issueNumber, issueContent, userStory.filePath);
188
+ if (result.wasReused) {
155
189
  issuesUpdated++;
156
- console.log(` \u2705 Updated Issue #${userStory.existingIssue}`);
157
- continue;
158
- } catch (err) {
159
- console.log(` \u26A0\uFE0F Issue #${userStory.existingIssue} deleted on GitHub, creating new`);
190
+ } else {
191
+ issuesCreated++;
160
192
  }
161
193
  }
162
- const titlePattern = `[${featureId}][${userStory.id}]`;
163
- const milestoneTitle = `${featureData.id}: ${featureData.title}`;
164
- console.log(` \u{1F6E1}\uFE0F Using DuplicateDetector (pattern: ${titlePattern})`);
165
- const result = await DuplicateDetector.createWithProtection({
166
- title: issueContent.title,
167
- body: issueContent.body,
168
- titlePattern,
169
- incrementId: userStory.id,
170
- labels: issueContent.labels,
171
- milestone: milestoneTitle,
172
- repo: `${this.client.getOwner()}/${this.client.getRepo()}`
173
- });
174
- issueNumber = result.issue.number;
175
- if (result.wasReused) {
176
- console.log(` \u267B\uFE0F Reused existing issue #${issueNumber} (duplicate prevented!)`);
177
- wasUpdated = true;
178
- } else {
179
- console.log(` \u2705 Created issue #${issueNumber}`);
180
- }
181
- if (result.duplicatesFound > 0) {
182
- console.log(` \u{1F6E1}\uFE0F Duplicates detected: ${result.duplicatesFound}, auto-closed: ${result.duplicatesClosed}`);
183
- }
184
- await this.updateUserStoryFrontmatter(userStory.filePath, issueNumber);
185
- await this.backfillIncrementMetadata(featureId, userStory.id, issueNumber, milestoneNumber);
186
- await this.updateUserStoryIssue(issueNumber, issueContent, userStory.filePath);
187
- if (result.wasReused) {
188
- issuesUpdated++;
189
- } else {
190
- issuesCreated++;
191
- }
192
- }
193
- console.log(`
194
+ console.log(`
194
195
  \u2705 Feature sync complete!`);
195
- console.log(` Milestone: ${milestoneUrl}`);
196
- console.log(` User Stories: ${userStories.length}`);
197
- console.log(` Issues created: ${issuesCreated}`);
198
- console.log(` Issues updated: ${issuesUpdated}`);
199
- return {
200
- milestoneNumber,
201
- milestoneUrl,
202
- issuesCreated,
203
- issuesUpdated,
204
- userStoriesProcessed: userStories.length
205
- };
196
+ console.log(` Milestone: ${milestoneUrl}`);
197
+ console.log(` User Stories: ${userStories.length}`);
198
+ console.log(` Issues created: ${issuesCreated}`);
199
+ console.log(` Issues updated: ${issuesUpdated}`);
200
+ return {
201
+ milestoneNumber,
202
+ milestoneUrl,
203
+ issuesCreated,
204
+ issuesUpdated,
205
+ userStoriesProcessed: userStories.length
206
+ };
207
+ } finally {
208
+ await lock.release();
209
+ }
206
210
  }
207
211
  /**
208
212
  * Find Feature folder in specs directory.
@@ -710,17 +714,42 @@ Created: ${featureData.created}`;
710
714
  */
711
715
  async updateUserStoryIssue(issueNumber, issueContent, userStoryPath) {
712
716
  const repoSlug = this.getRepoSlug();
713
- await execFileNoThrow("gh", [
714
- "issue",
715
- "edit",
716
- issueNumber.toString(),
717
- "--title",
718
- issueContent.title,
719
- "--body",
720
- issueContent.body,
721
- "-R",
722
- repoSlug
723
- ], { env: this.getGhEnv() });
717
+ let shouldEdit = true;
718
+ try {
719
+ const viewResult = await execFileNoThrow("gh", [
720
+ "issue",
721
+ "view",
722
+ issueNumber.toString(),
723
+ "--json",
724
+ "body",
725
+ "--jq",
726
+ ".body",
727
+ "-R",
728
+ repoSlug
729
+ ], { env: this.getGhEnv() });
730
+ if (viewResult.exitCode === 0 && viewResult.stdout) {
731
+ const currentNormalized = normalizeIssueBody(viewResult.stdout);
732
+ const newNormalized = normalizeIssueBody(issueContent.body);
733
+ if (currentNormalized === newNormalized) {
734
+ shouldEdit = false;
735
+ console.log(` \u23ED\uFE0F Body unchanged, skipping gh issue edit for #${issueNumber}`);
736
+ }
737
+ }
738
+ } catch {
739
+ }
740
+ if (shouldEdit) {
741
+ await execFileNoThrow("gh", [
742
+ "issue",
743
+ "edit",
744
+ issueNumber.toString(),
745
+ "--title",
746
+ issueContent.title,
747
+ "--body",
748
+ issueContent.body,
749
+ "-R",
750
+ repoSlug
751
+ ], { env: this.getGhEnv() });
752
+ }
724
753
  const completion = await this.calculator.calculateCompletion(userStoryPath);
725
754
  const issueData = await this.client.getIssue(issueNumber);
726
755
  const currentlyClosed = issueData.state === "closed";
@@ -996,12 +1025,7 @@ ${newFrontmatter}---${bodyContent}`;
996
1025
  ${newFrontmatter}---${bodyContent}`;
997
1026
  await writeFile(userStoryPath, newContent, "utf-8");
998
1027
  }
999
- };
1000
- // SYNC LOCK: Prevent concurrent syncs of the same feature
1001
- // Maps featureId → last sync timestamp
1002
- _GitHubFeatureSync.syncLocks = /* @__PURE__ */ new Map();
1003
- _GitHubFeatureSync.LOCK_DURATION_MS = 3e4;
1004
- let GitHubFeatureSync = _GitHubFeatureSync;
1028
+ }
1005
1029
  export {
1006
1030
  GitHubFeatureSync
1007
1031
  };
@@ -20,6 +20,8 @@ import { CompletionCalculator } from './completion-calculator.js';
20
20
  import { DuplicateDetector } from './duplicate-detector.js';
21
21
  import { execFileNoThrow } from '../../vendor/utils/execFileNoThrow.js';
22
22
  import { getGitHubAuthFromProject } from '../../vendor/utils/auth-helpers.js';
23
+ import { LockManager } from '../../../../../src/utils/lock-manager.js';
24
+ import { normalizeIssueBody } from './github-body-utils.js';
23
25
 
24
26
  interface FeatureFrontmatter {
25
27
  id: string;
@@ -57,11 +59,6 @@ export class GitHubFeatureSync {
57
59
  // Cached default branch for the sync session (one API call per session)
58
60
  private defaultBranch: string | null = null;
59
61
 
60
- // SYNC LOCK: Prevent concurrent syncs of the same feature
61
- // Maps featureId → last sync timestamp
62
- private static syncLocks: Map<string, number> = new Map();
63
- private static readonly LOCK_DURATION_MS = 30000; // 30 seconds
64
-
65
62
  constructor(client: GitHubClientV2, specsDir: string, projectRoot: string) {
66
63
  this.client = client;
67
64
  this.specsDir = specsDir;
@@ -134,18 +131,17 @@ export class GitHubFeatureSync {
134
131
  issuesUpdated: number;
135
132
  userStoriesProcessed: number;
136
133
  }> {
137
- // SYNC LOCK CHECK: Prevent concurrent/rapid syncs of the same feature+repo
134
+ // SYNC LOCK: Cross-process file lock prevents concurrent syncs of the same feature+repo
138
135
  // Root cause: Two sync paths (task completion + status change) can fire simultaneously
139
136
  // Result: Duplicate GitHub comments due to race condition
140
- // Key includes owner/repo so cross-project syncs (same featureId, different repos) aren't throttled
141
- const lockKey = `${this.client.getOwner()}/${this.client.getRepo()}:${featureId}`;
142
- const now = Date.now();
143
- const lastSync = GitHubFeatureSync.syncLocks.get(lockKey);
144
-
145
- if (lastSync && (now - lastSync) < GitHubFeatureSync.LOCK_DURATION_MS) {
146
- const secondsRemaining = Math.ceil((GitHubFeatureSync.LOCK_DURATION_MS - (now - lastSync)) / 1000);
147
- console.log(`\n⏭️ Sync already in progress for ${featureId} (or completed ${Math.floor((now - lastSync) / 1000)}s ago)`);
148
- console.log(` ℹ️ Sync will be available in ${secondsRemaining}s to prevent duplicates`);
137
+ const owner = this.client.getOwner();
138
+ const repo = this.client.getRepo();
139
+ const lockDir = path.join(this.projectRoot, '.specweave', 'state', 'locks', `github-sync-${owner}-${repo}`);
140
+ const lock = new LockManager(lockDir, 120);
141
+ const acquired = await lock.acquire();
142
+
143
+ if (!acquired) {
144
+ console.log(`\n⏭️ Sync already in progress for ${featureId} (lock held by another process)`);
149
145
  console.log(` 💡 This prevents race conditions between task completion and status change syncs`);
150
146
 
151
147
  // Return placeholder result (sync was skipped, not failed)
@@ -158,8 +154,7 @@ export class GitHubFeatureSync {
158
154
  };
159
155
  }
160
156
 
161
- // Acquire lock
162
- GitHubFeatureSync.syncLocks.set(lockKey, now);
157
+ try {
163
158
  console.log(`\n🔄 Syncing Feature ${featureId} to GitHub...`);
164
159
 
165
160
  // 1. Load Feature FEATURE.md
@@ -344,6 +339,9 @@ export class GitHubFeatureSync {
344
339
  issuesUpdated,
345
340
  userStoriesProcessed: userStories.length,
346
341
  };
342
+ } finally {
343
+ await lock.release();
344
+ }
347
345
  }
348
346
 
349
347
  /**
@@ -1016,19 +1014,41 @@ export class GitHubFeatureSync {
1016
1014
  },
1017
1015
  userStoryPath: string
1018
1016
  ): Promise<void> {
1019
- // Update issue body
1017
+ // Body diff check: skip `gh issue edit` when body is unchanged (FS-587)
1020
1018
  const repoSlug = this.getRepoSlug();
1021
- await execFileNoThrow('gh', [
1022
- 'issue',
1023
- 'edit',
1024
- issueNumber.toString(),
1025
- '--title',
1026
- issueContent.title,
1027
- '--body',
1028
- issueContent.body,
1029
- '-R',
1030
- repoSlug,
1031
- ], { env: this.getGhEnv() });
1019
+ let shouldEdit = true;
1020
+ try {
1021
+ const viewResult = await execFileNoThrow('gh', [
1022
+ 'issue', 'view', issueNumber.toString(),
1023
+ '--json', 'body', '--jq', '.body',
1024
+ '-R', repoSlug,
1025
+ ], { env: this.getGhEnv() });
1026
+
1027
+ if (viewResult.exitCode === 0 && viewResult.stdout) {
1028
+ const currentNormalized = normalizeIssueBody(viewResult.stdout);
1029
+ const newNormalized = normalizeIssueBody(issueContent.body);
1030
+ if (currentNormalized === newNormalized) {
1031
+ shouldEdit = false;
1032
+ console.log(` ⏭️ Body unchanged, skipping gh issue edit for #${issueNumber}`);
1033
+ }
1034
+ }
1035
+ } catch {
1036
+ // 404 or other error fetching current body — proceed with edit
1037
+ }
1038
+
1039
+ if (shouldEdit) {
1040
+ await execFileNoThrow('gh', [
1041
+ 'issue',
1042
+ 'edit',
1043
+ issueNumber.toString(),
1044
+ '--title',
1045
+ issueContent.title,
1046
+ '--body',
1047
+ issueContent.body,
1048
+ '-R',
1049
+ repoSlug,
1050
+ ], { env: this.getGhEnv() });
1051
+ }
1032
1052
 
1033
1053
  // ✅ VERIFICATION GATE: Calculate ACTUAL completion from checkboxes
1034
1054
  const completion = await this.calculator.calculateCompletion(userStoryPath);