specweave 0.12.0 → 0.12.1

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.
@@ -0,0 +1,147 @@
1
+ ---
2
+ name: specweave-github:sync-from
3
+ description: Sync state from GitHub to SpecWeave (bidirectional sync). Fetches issue state, comments, and detects conflicts.
4
+ ---
5
+
6
+ # Sync from GitHub to SpecWeave
7
+
8
+ Bidirectional sync - pull changes from GitHub issue back to SpecWeave increment.
9
+
10
+ ## Usage
11
+
12
+ ```bash
13
+ /specweave-github:sync-from <incrementId>
14
+ ```
15
+
16
+ ## What It Does
17
+
18
+ 1. **Fetches GitHub Issue State**:
19
+ - Issue status (open/closed)
20
+ - Comments
21
+ - Labels, assignees, milestones
22
+ - Last updated timestamp
23
+
24
+ 2. **Detects Conflicts**:
25
+ - GitHub closed but SpecWeave active
26
+ - SpecWeave completed but GitHub open
27
+ - GitHub abandoned but issue open
28
+
29
+ 3. **Syncs Comments**:
30
+ - Saves GitHub comments to `.specweave/increments/<id>/logs/github-comments.md`
31
+ - Only syncs new comments (tracks IDs)
32
+
33
+ 4. **Updates Metadata**:
34
+ - Updates `metadata.json` with latest GitHub state
35
+ - Tracks last sync timestamp
36
+
37
+ ## Examples
38
+
39
+ ### Basic Sync
40
+
41
+ ```bash
42
+ /specweave-github:sync-from 0015-hierarchical-sync
43
+ ```
44
+
45
+ **Output**:
46
+ ```
47
+ 🔄 Syncing from GitHub for increment: 0015-hierarchical-sync
48
+ Syncing from anton-abyzov/specweave#29
49
+ ✅ No conflicts - GitHub and SpecWeave in sync
50
+ 📝 Syncing 3 new comment(s)
51
+ ✅ Comments saved to: logs/github-comments.md
52
+ ✅ Metadata updated
53
+ ✅ Bidirectional sync complete
54
+ ```
55
+
56
+ ### Conflict Detection
57
+
58
+ ```bash
59
+ /specweave-github:sync-from 0015-hierarchical-sync
60
+ ```
61
+
62
+ **Output**:
63
+ ```
64
+ 🔄 Syncing from GitHub for increment: 0015-hierarchical-sync
65
+ Syncing from anton-abyzov/specweave#29
66
+ ⚠️ Detected 1 conflict(s)
67
+
68
+ ⚠️ Conflict detected: status
69
+ GitHub: closed
70
+ SpecWeave: active
71
+
72
+ ⚠️ **CONFLICT**: GitHub issue closed but SpecWeave increment still active!
73
+ Recommendation: Run /specweave:done 0015-hierarchical-sync to close increment
74
+ Or reopen issue on GitHub if work is not complete
75
+
76
+ ✅ Bidirectional sync complete
77
+ ```
78
+
79
+ ## When to Use
80
+
81
+ Use this command when:
82
+ - ✅ Someone closed/reopened the GitHub issue
83
+ - ✅ Comments were added on GitHub (want to import them)
84
+ - ✅ Want to check if GitHub and SpecWeave are in sync
85
+ - ✅ Resolving conflicts between GitHub and SpecWeave state
86
+
87
+ ## Requirements
88
+
89
+ - GitHub CLI (`gh`) installed and authenticated
90
+ - GitHub issue linked in metadata.json
91
+ - Repository has GitHub remote configured
92
+
93
+ ## Conflict Resolution
94
+
95
+ ### GitHub Closed, SpecWeave Active
96
+
97
+ **Resolution**: Close SpecWeave increment
98
+ ```bash
99
+ /specweave:done 0015-hierarchical-sync
100
+ ```
101
+
102
+ **Or**: Reopen GitHub issue if work not complete
103
+ ```bash
104
+ gh issue reopen 29
105
+ ```
106
+
107
+ ### SpecWeave Completed, GitHub Open
108
+
109
+ **Resolution**: Close GitHub issue
110
+ ```bash
111
+ gh issue close 29 --comment "Increment completed in SpecWeave"
112
+ ```
113
+
114
+ ### SpecWeave Abandoned, GitHub Open
115
+
116
+ **Resolution**: Close GitHub issue with reason
117
+ ```bash
118
+ gh issue close 29 --comment "Increment abandoned: Requirements changed"
119
+ ```
120
+
121
+ ## Files Modified
122
+
123
+ - `.specweave/increments/<id>/logs/github-comments.md` - GitHub comments
124
+ - `.specweave/increments/<id>/metadata.json` - Sync metadata
125
+
126
+ ## Related Commands
127
+
128
+ - `/specweave-github:sync` - One-way sync (SpecWeave → GitHub)
129
+ - `/specweave-github:create-issue` - Create GitHub issue
130
+ - `/specweave-github:close-issue` - Close GitHub issue
131
+ - `/specweave-github:status` - Check sync status
132
+
133
+ ## Automation
134
+
135
+ For automatic bidirectional sync, add to cron or CI/CD:
136
+
137
+ ```bash
138
+ # Sync all active increments hourly
139
+ 0 * * * * cd /path/to/project && \
140
+ for inc in $(ls .specweave/increments/ | grep -v _backlog); do \
141
+ /specweave-github:sync-from $inc; \
142
+ done
143
+ ```
144
+
145
+ ## Implementation
146
+
147
+ Invokes `github-sync-bidirectional` agent with conflict detection and resolution logic.
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI wrapper for syncing increment changes to GitHub
5
+ *
6
+ * Usage:
7
+ * node dist/plugins/specweave-github/lib/cli-sync-increment-changes.js <incrementId> <changedFile>
8
+ *
9
+ * Example:
10
+ * node dist/plugins/specweave-github/lib/cli-sync-increment-changes.js 0015-hierarchical-sync spec.md
11
+ */
12
+
13
+ import { syncIncrementChanges } from './github-sync-increment-changes.js';
14
+
15
+ const incrementId = process.argv[2];
16
+ const changedFile = process.argv[3] as 'spec.md' | 'plan.md' | 'tasks.md';
17
+
18
+ if (!incrementId || !changedFile) {
19
+ console.error('❌ Usage: cli-sync-increment-changes <incrementId> <changedFile>');
20
+ console.error(' Example: cli-sync-increment-changes 0015-hierarchical-sync spec.md');
21
+ process.exit(1);
22
+ }
23
+
24
+ if (!['spec.md', 'plan.md', 'tasks.md'].includes(changedFile)) {
25
+ console.error(`❌ Invalid file: ${changedFile}`);
26
+ console.error(' Must be one of: spec.md, plan.md, tasks.md');
27
+ process.exit(1);
28
+ }
29
+
30
+ syncIncrementChanges(incrementId, changedFile).catch((error) => {
31
+ console.error('❌ Fatal error:', error);
32
+ // Don't exit with error code - best-effort sync
33
+ });
@@ -0,0 +1,449 @@
1
+ /**
2
+ * GitHub Issue Updater for Living Docs Sync
3
+ *
4
+ * Handles updating GitHub issues with living documentation links and content.
5
+ * Used by post-task-completion hook to keep GitHub issues in sync with SpecWeave docs.
6
+ *
7
+ * @module github-issue-updater
8
+ */
9
+
10
+ import fs from 'fs-extra';
11
+ import path from 'path';
12
+ import { execFileNoThrow } from '../../../src/utils/execFileNoThrow.js';
13
+
14
+ export interface LivingDocsSection {
15
+ specs: string[];
16
+ architecture: string[];
17
+ diagrams: string[];
18
+ }
19
+
20
+ export interface IncrementMetadata {
21
+ id: string;
22
+ status: string;
23
+ type: string;
24
+ github?: {
25
+ issue: number;
26
+ url: string;
27
+ synced?: string;
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Update GitHub issue with living docs section
33
+ */
34
+ export async function updateIssueLivingDocs(
35
+ issueNumber: number,
36
+ livingDocs: LivingDocsSection,
37
+ owner: string,
38
+ repo: string
39
+ ): Promise<void> {
40
+ console.log(`📝 Updating GitHub issue #${issueNumber} with living docs...`);
41
+
42
+ // 1. Get current issue body
43
+ const currentBody = await getIssueBody(issueNumber, owner, repo);
44
+
45
+ // 2. Build living docs section
46
+ const livingDocsSection = buildLivingDocsSection(livingDocs, owner, repo);
47
+
48
+ // 3. Update or append living docs section
49
+ const updatedBody = updateBodyWithLivingDocs(currentBody, livingDocsSection);
50
+
51
+ // 4. Update issue
52
+ await updateIssueBody(issueNumber, updatedBody, owner, repo);
53
+
54
+ console.log(`✅ Living docs section updated in issue #${issueNumber}`);
55
+ }
56
+
57
+ /**
58
+ * Post comment about ADR/HLD/diagram creation
59
+ */
60
+ export async function postArchitectureComment(
61
+ issueNumber: number,
62
+ docPath: string,
63
+ owner: string,
64
+ repo: string
65
+ ): Promise<void> {
66
+ const docType = getDocType(docPath);
67
+ const docName = path.basename(docPath, '.md');
68
+ const docUrl = `https://github.com/${owner}/${repo}/blob/develop/${docPath}`;
69
+
70
+ const comment = `
71
+ 🏗️ **${docType} Created**
72
+
73
+ **Document**: [${docName}](${docUrl})
74
+
75
+ **Path**: \`${docPath}\`
76
+
77
+ ---
78
+ 🤖 Auto-updated by SpecWeave
79
+ `.trim();
80
+
81
+ await postComment(issueNumber, comment, owner, repo);
82
+ console.log(`✅ Posted ${docType} comment to issue #${issueNumber}`);
83
+ }
84
+
85
+ /**
86
+ * Post scope change comment
87
+ */
88
+ export async function postScopeChangeComment(
89
+ issueNumber: number,
90
+ changes: {
91
+ added?: string[];
92
+ removed?: string[];
93
+ modified?: string[];
94
+ reason?: string;
95
+ impact?: string;
96
+ },
97
+ owner: string,
98
+ repo: string
99
+ ): Promise<void> {
100
+ const parts: string[] = ['**Scope Change Detected**', ''];
101
+
102
+ if (changes.added && changes.added.length > 0) {
103
+ parts.push('**Added**:');
104
+ changes.added.forEach(item => parts.push(`- ✅ ${item}`));
105
+ parts.push('');
106
+ }
107
+
108
+ if (changes.removed && changes.removed.length > 0) {
109
+ parts.push('**Removed**:');
110
+ changes.removed.forEach(item => parts.push(`- ❌ ${item}`));
111
+ parts.push('');
112
+ }
113
+
114
+ if (changes.modified && changes.modified.length > 0) {
115
+ parts.push('**Modified**:');
116
+ changes.modified.forEach(item => parts.push(`- 🔄 ${item}`));
117
+ parts.push('');
118
+ }
119
+
120
+ if (changes.reason) {
121
+ parts.push(`**Reason**: ${changes.reason}`);
122
+ parts.push('');
123
+ }
124
+
125
+ if (changes.impact) {
126
+ parts.push(`**Impact**: ${changes.impact}`);
127
+ parts.push('');
128
+ }
129
+
130
+ parts.push('---');
131
+ parts.push('🤖 Auto-updated by SpecWeave');
132
+
133
+ await postComment(issueNumber, parts.join('\n'), owner, repo);
134
+ console.log(`✅ Posted scope change comment to issue #${issueNumber}`);
135
+ }
136
+
137
+ /**
138
+ * Post status change comment (pause/resume/abandon)
139
+ */
140
+ export async function postStatusChangeComment(
141
+ issueNumber: number,
142
+ status: 'paused' | 'resumed' | 'abandoned',
143
+ reason: string,
144
+ owner: string,
145
+ repo: string
146
+ ): Promise<void> {
147
+ const emoji = {
148
+ paused: '⏸️',
149
+ resumed: '▶️',
150
+ abandoned: '🗑️'
151
+ }[status];
152
+
153
+ const title = {
154
+ paused: 'Increment Paused',
155
+ resumed: 'Increment Resumed',
156
+ abandoned: 'Increment Abandoned'
157
+ }[status];
158
+
159
+ const comment = `
160
+ ${emoji} **${title}**
161
+
162
+ **Reason**: ${reason}
163
+
164
+ **Timestamp**: ${new Date().toISOString()}
165
+
166
+ ---
167
+ 🤖 Auto-updated by SpecWeave
168
+ `.trim();
169
+
170
+ await postComment(issueNumber, comment, owner, repo);
171
+ console.log(`✅ Posted ${status} comment to issue #${issueNumber}`);
172
+ }
173
+
174
+ /**
175
+ * Collect living docs for an increment
176
+ */
177
+ export async function collectLivingDocs(incrementId: string): Promise<LivingDocsSection> {
178
+ const livingDocs: LivingDocsSection = {
179
+ specs: [],
180
+ architecture: [],
181
+ diagrams: []
182
+ };
183
+
184
+ // 1. Find specs
185
+ const specsDir = path.join(process.cwd(), '.specweave/docs/internal/specs');
186
+ if (await fs.pathExists(specsDir)) {
187
+ const specFiles = await fs.readdir(specsDir);
188
+ for (const file of specFiles) {
189
+ if (file.endsWith('.md') && !file.startsWith('README')) {
190
+ livingDocs.specs.push(path.join('.specweave/docs/internal/specs', file));
191
+ }
192
+ }
193
+ }
194
+
195
+ // 2. Find architecture docs (ADRs, HLDs)
196
+ const archDir = path.join(process.cwd(), '.specweave/docs/internal/architecture');
197
+ if (await fs.pathExists(archDir)) {
198
+ // ADRs
199
+ const adrDir = path.join(archDir, 'adr');
200
+ if (await fs.pathExists(adrDir)) {
201
+ const adrFiles = await fs.readdir(adrDir);
202
+ for (const file of adrFiles) {
203
+ if (file.endsWith('.md')) {
204
+ livingDocs.architecture.push(path.join('.specweave/docs/internal/architecture/adr', file));
205
+ }
206
+ }
207
+ }
208
+
209
+ // HLDs
210
+ const hldFiles = await fs.readdir(archDir);
211
+ for (const file of hldFiles) {
212
+ if (file.startsWith('hld-') && file.endsWith('.md')) {
213
+ livingDocs.architecture.push(path.join('.specweave/docs/internal/architecture', file));
214
+ }
215
+ }
216
+ }
217
+
218
+ // 3. Find diagrams
219
+ const diagramsDir = path.join(process.cwd(), '.specweave/docs/internal/architecture/diagrams');
220
+ if (await fs.pathExists(diagramsDir)) {
221
+ const diagramFiles = await fs.readdir(diagramsDir);
222
+ for (const file of diagramFiles) {
223
+ if (file.endsWith('.mmd') || file.endsWith('.svg')) {
224
+ livingDocs.diagrams.push(path.join('.specweave/docs/internal/architecture/diagrams', file));
225
+ }
226
+ }
227
+ }
228
+
229
+ return livingDocs;
230
+ }
231
+
232
+ /**
233
+ * Load increment metadata
234
+ */
235
+ export async function loadIncrementMetadata(incrementId: string): Promise<IncrementMetadata | null> {
236
+ const metadataPath = path.join(
237
+ process.cwd(),
238
+ '.specweave/increments',
239
+ incrementId,
240
+ 'metadata.json'
241
+ );
242
+
243
+ if (!await fs.pathExists(metadataPath)) {
244
+ return null;
245
+ }
246
+
247
+ return await fs.readJson(metadataPath);
248
+ }
249
+
250
+ /**
251
+ * Detect repository owner and name from git remote
252
+ */
253
+ export async function detectRepo(): Promise<{ owner: string; repo: string } | null> {
254
+ try {
255
+ const result = await execFileNoThrow('git', ['remote', 'get-url', 'origin']);
256
+
257
+ if (result.status !== 0) {
258
+ return null;
259
+ }
260
+
261
+ const remote = result.stdout.trim();
262
+ const match = remote.match(/github\.com[:/](.+)\/(.+?)(?:\.git)?$/);
263
+
264
+ if (!match) {
265
+ return null;
266
+ }
267
+
268
+ return {
269
+ owner: match[1],
270
+ repo: match[2]
271
+ };
272
+ } catch (error) {
273
+ return null;
274
+ }
275
+ }
276
+
277
+ // =============================================================================
278
+ // PRIVATE HELPERS
279
+ // =============================================================================
280
+
281
+ /**
282
+ * Get current issue body
283
+ */
284
+ async function getIssueBody(
285
+ issueNumber: number,
286
+ owner: string,
287
+ repo: string
288
+ ): Promise<string> {
289
+ const result = await execFileNoThrow('gh', [
290
+ 'issue',
291
+ 'view',
292
+ String(issueNumber),
293
+ '--repo',
294
+ `${owner}/${repo}`,
295
+ '--json',
296
+ 'body',
297
+ '-q',
298
+ '.body'
299
+ ]);
300
+
301
+ if (result.status !== 0) {
302
+ throw new Error(`Failed to get issue body: ${result.stderr}`);
303
+ }
304
+
305
+ return result.stdout.trim();
306
+ }
307
+
308
+ /**
309
+ * Update issue body
310
+ */
311
+ async function updateIssueBody(
312
+ issueNumber: number,
313
+ body: string,
314
+ owner: string,
315
+ repo: string
316
+ ): Promise<void> {
317
+ const result = await execFileNoThrow('gh', [
318
+ 'issue',
319
+ 'edit',
320
+ String(issueNumber),
321
+ '--repo',
322
+ `${owner}/${repo}`,
323
+ '--body',
324
+ body
325
+ ]);
326
+
327
+ if (result.status !== 0) {
328
+ throw new Error(`Failed to update issue body: ${result.stderr}`);
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Post comment to issue
334
+ */
335
+ async function postComment(
336
+ issueNumber: number,
337
+ comment: string,
338
+ owner: string,
339
+ repo: string
340
+ ): Promise<void> {
341
+ const result = await execFileNoThrow('gh', [
342
+ 'issue',
343
+ 'comment',
344
+ String(issueNumber),
345
+ '--repo',
346
+ `${owner}/${repo}`,
347
+ '--body',
348
+ comment
349
+ ]);
350
+
351
+ if (result.status !== 0) {
352
+ throw new Error(`Failed to post comment: ${result.stderr}`);
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Build living docs section for issue body
358
+ */
359
+ function buildLivingDocsSection(
360
+ livingDocs: LivingDocsSection,
361
+ owner: string,
362
+ repo: string
363
+ ): string {
364
+ const parts: string[] = ['## 📚 Living Documentation', ''];
365
+
366
+ // Specs
367
+ if (livingDocs.specs.length > 0) {
368
+ parts.push('**Specifications**:');
369
+ livingDocs.specs.forEach(spec => {
370
+ const name = path.basename(spec, '.md');
371
+ const url = `https://github.com/${owner}/${repo}/blob/develop/${spec}`;
372
+ parts.push(`- [${name}](${url})`);
373
+ });
374
+ parts.push('');
375
+ }
376
+
377
+ // Architecture
378
+ if (livingDocs.architecture.length > 0) {
379
+ parts.push('**Architecture**:');
380
+ livingDocs.architecture.forEach(doc => {
381
+ const name = path.basename(doc, '.md');
382
+ const url = `https://github.com/${owner}/${repo}/blob/develop/${doc}`;
383
+ parts.push(`- [${name}](${url})`);
384
+ });
385
+ parts.push('');
386
+ }
387
+
388
+ // Diagrams
389
+ if (livingDocs.diagrams.length > 0) {
390
+ parts.push('**Diagrams**:');
391
+ livingDocs.diagrams.forEach(diagram => {
392
+ const name = path.basename(diagram);
393
+ const url = `https://github.com/${owner}/${repo}/blob/develop/${diagram}`;
394
+ parts.push(`- [${name}](${url})`);
395
+ });
396
+ parts.push('');
397
+ }
398
+
399
+ parts.push('---');
400
+
401
+ return parts.join('\n');
402
+ }
403
+
404
+ /**
405
+ * Update body with living docs section
406
+ */
407
+ function updateBodyWithLivingDocs(
408
+ currentBody: string,
409
+ livingDocsSection: string
410
+ ): string {
411
+ // Check if living docs section already exists
412
+ const marker = '## 📚 Living Documentation';
413
+
414
+ if (currentBody.includes(marker)) {
415
+ // Replace existing section
416
+ const beforeMarker = currentBody.substring(0, currentBody.indexOf(marker));
417
+ const afterMarker = currentBody.substring(currentBody.indexOf(marker));
418
+
419
+ // Find end of section (next ## header or end of body)
420
+ const nextSection = afterMarker.indexOf('\n## ', 1);
421
+ const replacement = nextSection > 0
422
+ ? afterMarker.substring(0, nextSection)
423
+ : afterMarker;
424
+
425
+ return beforeMarker + livingDocsSection + (nextSection > 0 ? afterMarker.substring(nextSection) : '');
426
+ } else {
427
+ // Append at end
428
+ return currentBody + '\n\n' + livingDocsSection;
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Determine document type from path
434
+ */
435
+ function getDocType(docPath: string): string {
436
+ if (docPath.includes('/adr/')) {
437
+ return 'Architecture Decision Record (ADR)';
438
+ }
439
+ if (docPath.includes('/diagrams/')) {
440
+ return 'Architecture Diagram';
441
+ }
442
+ if (docPath.startsWith('hld-')) {
443
+ return 'High-Level Design (HLD)';
444
+ }
445
+ if (docPath.includes('/specs/')) {
446
+ return 'Specification';
447
+ }
448
+ return 'Documentation';
449
+ }