claude-autopm 1.24.0 → 1.25.0

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,498 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Epic Sync - Complete GitHub sync for epics
4
+ *
5
+ * Replaces all 4 Bash scripts with clean, testable JavaScript:
6
+ * - create-epic-issue.sh → createEpicIssue()
7
+ * - create-task-issues.sh → createTaskIssues()
8
+ * - update-epic-file.sh → updateEpicFile()
9
+ * - update-references.sh → updateTaskReferences()
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { execSync } = require('child_process');
15
+
16
+ /**
17
+ * Parse frontmatter and body from markdown file
18
+ */
19
+ function parseMarkdownFile(filePath) {
20
+ const content = fs.readFileSync(filePath, 'utf8');
21
+ const lines = content.split('\n');
22
+
23
+ let inFrontmatter = false;
24
+ let frontmatterLines = [];
25
+ let bodyLines = [];
26
+ let frontmatterCount = 0;
27
+
28
+ for (const line of lines) {
29
+ if (line === '---') {
30
+ frontmatterCount++;
31
+ if (frontmatterCount === 1) {
32
+ inFrontmatter = true;
33
+ continue;
34
+ } else if (frontmatterCount === 2) {
35
+ inFrontmatter = false;
36
+ continue;
37
+ }
38
+ }
39
+
40
+ if (inFrontmatter) {
41
+ frontmatterLines.push(line);
42
+ } else if (frontmatterCount === 2) {
43
+ bodyLines.push(line);
44
+ }
45
+ }
46
+
47
+ // Parse frontmatter
48
+ const frontmatter = {};
49
+ for (const line of frontmatterLines) {
50
+ const match = line.match(/^(\w+):\s*(.+)$/);
51
+ if (match) {
52
+ const [, key, value] = match;
53
+ frontmatter[key] = value;
54
+ }
55
+ }
56
+
57
+ return {
58
+ frontmatter,
59
+ frontmatterLines,
60
+ body: bodyLines.join('\n').trim(),
61
+ fullContent: content
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Update frontmatter field in markdown content
67
+ */
68
+ function updateFrontmatter(content, updates) {
69
+ const lines = content.split('\n');
70
+ const result = [];
71
+ let inFrontmatter = false;
72
+ let frontmatterCount = 0;
73
+
74
+ for (const line of lines) {
75
+ if (line === '---') {
76
+ frontmatterCount++;
77
+ result.push(line);
78
+ if (frontmatterCount === 1) {
79
+ inFrontmatter = true;
80
+ } else if (frontmatterCount === 2) {
81
+ inFrontmatter = false;
82
+ }
83
+ continue;
84
+ }
85
+
86
+ if (inFrontmatter) {
87
+ const match = line.match(/^(\w+):\s*(.+)$/);
88
+ if (match) {
89
+ const [, key] = match;
90
+ if (updates[key] !== undefined) {
91
+ result.push(`${key}: ${updates[key]}`);
92
+ } else {
93
+ result.push(line);
94
+ }
95
+ } else {
96
+ result.push(line);
97
+ }
98
+ } else {
99
+ result.push(line);
100
+ }
101
+ }
102
+
103
+ return result.join('\n');
104
+ }
105
+
106
+ /**
107
+ * Get current timestamp in ISO format
108
+ */
109
+ function getTimestamp() {
110
+ return new Date().toISOString();
111
+ }
112
+
113
+ /**
114
+ * Get repository info from gh CLI
115
+ */
116
+ function getRepoInfo() {
117
+ try {
118
+ const result = execSync('gh repo view --json nameWithOwner -q .nameWithOwner', {
119
+ encoding: 'utf8',
120
+ stdio: ['pipe', 'pipe', 'pipe']
121
+ });
122
+ return result.trim();
123
+ } catch (error) {
124
+ return 'unknown/repo';
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Create epic GitHub issue
130
+ */
131
+ function createEpicIssue(epicPath) {
132
+ const epicFile = path.join(process.cwd(), '.claude/epics', epicPath, 'epic.md');
133
+
134
+ if (!fs.existsSync(epicFile)) {
135
+ throw new Error(`Epic file not found: ${epicFile}`);
136
+ }
137
+
138
+ const { body } = parseMarkdownFile(epicFile);
139
+
140
+ // Count tasks
141
+ const epicDir = path.dirname(epicFile);
142
+ const taskFiles = fs.readdirSync(epicDir)
143
+ .filter(f => /^\d+\.md$/.test(f));
144
+ const taskCount = taskFiles.length;
145
+
146
+ // Detect epic type
147
+ const isFeature = body.toLowerCase().includes('feature') ||
148
+ body.toLowerCase().includes('implement') ||
149
+ !body.toLowerCase().includes('bug');
150
+ const labels = isFeature ? 'epic,feature' : 'epic,bug';
151
+
152
+ console.log(`📝 Creating epic issue: ${epicPath}`);
153
+ console.log(` Tasks: ${taskCount}`);
154
+ console.log(` Labels: ${labels}`);
155
+
156
+ // Create issue body
157
+ const issueBody = `
158
+ ${body}
159
+
160
+ ---
161
+
162
+ **Epic Statistics:**
163
+ - Tasks: ${taskCount}
164
+ - Status: Planning
165
+ - Created: ${getTimestamp()}
166
+ `.trim();
167
+
168
+ // Escape for shell
169
+ const escapedTitle = `Epic: ${epicPath}`.replace(/"/g, '\\"');
170
+ const escapedBody = issueBody.replace(/"/g, '\\"').replace(/`/g, '\\`');
171
+
172
+ try {
173
+ const result = execSync(
174
+ `gh issue create --title "${escapedTitle}" --body "${escapedBody}" --label "${labels}"`,
175
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
176
+ );
177
+
178
+ const match = result.match(/https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)/);
179
+ if (match) {
180
+ const issueNumber = parseInt(match[1]);
181
+ console.log(`✅ Created epic issue #${issueNumber}`);
182
+ return issueNumber;
183
+ }
184
+
185
+ throw new Error('Could not extract issue number from gh output');
186
+ } catch (error) {
187
+ throw new Error(`Failed to create epic issue: ${error.message}`);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Create task issues for an epic
193
+ */
194
+ function createTaskIssues(epicPath, epicIssueNumber) {
195
+ const epicDir = path.join(process.cwd(), '.claude/epics', epicPath);
196
+
197
+ if (!fs.existsSync(epicDir)) {
198
+ throw new Error(`Epic directory not found: ${epicDir}`);
199
+ }
200
+
201
+ // Find all task files
202
+ const taskFiles = fs.readdirSync(epicDir)
203
+ .filter(f => /^\d+\.md$/.test(f))
204
+ .sort();
205
+
206
+ if (taskFiles.length === 0) {
207
+ throw new Error(`No task files found in ${epicDir}`);
208
+ }
209
+
210
+ console.log(`\n📋 Creating ${taskFiles.length} task issues for epic #${epicIssueNumber}`);
211
+
212
+ const mapping = [];
213
+ let successCount = 0;
214
+
215
+ for (let i = 0; i < taskFiles.length; i++) {
216
+ const taskFile = taskFiles[i];
217
+ const taskNumber = taskFile.replace('.md', '');
218
+ const taskPath = path.join(epicDir, taskFile);
219
+
220
+ console.log(`[${i + 1}/${taskFiles.length}] Creating issue for task ${taskNumber}...`);
221
+
222
+ try {
223
+ const { frontmatter, body } = parseMarkdownFile(taskPath);
224
+ const title = frontmatter.name || 'Untitled Task';
225
+
226
+ // Create issue body
227
+ const issueBody = `
228
+ Part of Epic #${epicIssueNumber}
229
+
230
+ ---
231
+
232
+ ${body}
233
+
234
+ ---
235
+
236
+ **Epic Path:** \`${epicPath}\`
237
+ **Task Number:** ${taskNumber}
238
+ `.trim();
239
+
240
+ // Escape for shell
241
+ const escapedTitle = title.replace(/"/g, '\\"');
242
+ const escapedBody = issueBody.replace(/"/g, '\\"').replace(/`/g, '\\`');
243
+
244
+ const result = execSync(
245
+ `gh issue create --title "${escapedTitle}" --body "${escapedBody}" --label "task"`,
246
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
247
+ );
248
+
249
+ const match = result.match(/https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)/);
250
+ if (match) {
251
+ const issueNumber = parseInt(match[1]);
252
+ console.log(` ✅ Created issue #${issueNumber}: ${title}`);
253
+ mapping.push({ oldName: taskNumber, newNumber: issueNumber });
254
+ successCount++;
255
+ }
256
+ } catch (error) {
257
+ console.error(` ❌ Error: ${error.message}`);
258
+ }
259
+ }
260
+
261
+ // Save mapping file
262
+ const mappingFile = path.join(epicDir, '.task-mapping.txt');
263
+ const mappingContent = mapping.map(m => `${m.oldName} ${m.newNumber}`).join('\n');
264
+ fs.writeFileSync(mappingFile, mappingContent, 'utf8');
265
+
266
+ console.log(`\n📊 Summary:`);
267
+ console.log(` ✅ Success: ${successCount}`);
268
+ console.log(` ❌ Failed: ${taskFiles.length - successCount}`);
269
+ console.log(` 📝 Total: ${taskFiles.length}`);
270
+
271
+ return mapping;
272
+ }
273
+
274
+ /**
275
+ * Update epic file with GitHub URL and task references
276
+ */
277
+ function updateEpicFile(epicPath, epicIssueNumber, taskMapping) {
278
+ const epicFile = path.join(process.cwd(), '.claude/epics', epicPath, 'epic.md');
279
+
280
+ if (!fs.existsSync(epicFile)) {
281
+ throw new Error(`Epic file not found: ${epicFile}`);
282
+ }
283
+
284
+ console.log(`\n📄 Updating epic file: ${epicFile}`);
285
+
286
+ const repo = getRepoInfo();
287
+ const epicUrl = `https://github.com/${repo}/issues/${epicIssueNumber}`;
288
+
289
+ // Read current content
290
+ let content = fs.readFileSync(epicFile, 'utf8');
291
+
292
+ // Update frontmatter
293
+ content = updateFrontmatter(content, {
294
+ github: epicUrl,
295
+ updated: getTimestamp()
296
+ });
297
+
298
+ // Update task references in body
299
+ if (taskMapping && taskMapping.length > 0) {
300
+ console.log(' Updating task references...');
301
+
302
+ for (const { oldName, newNumber } of taskMapping) {
303
+ // Update checkbox items
304
+ content = content.replace(
305
+ new RegExp(`- \\[ \\] ${oldName}\\b`, 'g'),
306
+ `- [ ] #${newNumber}`
307
+ );
308
+ content = content.replace(
309
+ new RegExp(`- \\[x\\] ${oldName}\\b`, 'g'),
310
+ `- [x] #${newNumber}`
311
+ );
312
+
313
+ // Update task links
314
+ content = content.replace(
315
+ new RegExp(`Task ${oldName}\\b`, 'g'),
316
+ `Task #${newNumber}`
317
+ );
318
+ }
319
+ }
320
+
321
+ // Write updated content
322
+ fs.writeFileSync(epicFile, content, 'utf8');
323
+
324
+ console.log('✅ Epic file updated');
325
+ console.log(` GitHub: ${epicUrl}`);
326
+ }
327
+
328
+ /**
329
+ * Update task files with GitHub URLs and rename to issue numbers
330
+ */
331
+ function updateTaskReferences(epicPath, taskMapping) {
332
+ const epicDir = path.join(process.cwd(), '.claude/epics', epicPath);
333
+
334
+ if (!fs.existsSync(epicDir)) {
335
+ throw new Error(`Epic directory not found: ${epicDir}`);
336
+ }
337
+
338
+ console.log(`\n🔗 Updating task references and renaming files`);
339
+
340
+ const repo = getRepoInfo();
341
+
342
+ for (const { oldName, newNumber } of taskMapping) {
343
+ const oldFile = path.join(epicDir, `${oldName}.md`);
344
+ const newFile = path.join(epicDir, `${newNumber}.md`);
345
+
346
+ if (!fs.existsSync(oldFile)) {
347
+ console.log(` ⚠️ File not found: ${oldName}.md (skipping)`);
348
+ continue;
349
+ }
350
+
351
+ console.log(` Renaming ${oldName}.md → ${newNumber}.md...`);
352
+
353
+ // Read and update content
354
+ let content = fs.readFileSync(oldFile, 'utf8');
355
+
356
+ const githubUrl = `https://github.com/${repo}/issues/${newNumber}`;
357
+ content = updateFrontmatter(content, {
358
+ github: githubUrl,
359
+ updated: getTimestamp()
360
+ });
361
+
362
+ // Write to new file
363
+ fs.writeFileSync(newFile, content, 'utf8');
364
+
365
+ // Remove old file
366
+ fs.unlinkSync(oldFile);
367
+
368
+ console.log(` ✓`);
369
+ }
370
+
371
+ console.log('\n✅ Task files renamed and frontmatter updated');
372
+ }
373
+
374
+ /**
375
+ * Full epic sync workflow
376
+ */
377
+ function syncEpic(epicPath) {
378
+ console.log(`🚀 Starting full epic sync: ${epicPath}\n`);
379
+
380
+ try {
381
+ // Step 1: Create epic issue
382
+ const epicIssueNumber = createEpicIssue(epicPath);
383
+
384
+ // Step 2: Create task issues
385
+ const taskMapping = createTaskIssues(epicPath, epicIssueNumber);
386
+
387
+ // Step 3: Update epic file
388
+ updateEpicFile(epicPath, epicIssueNumber, taskMapping);
389
+
390
+ // Step 4: Update task references
391
+ updateTaskReferences(epicPath, taskMapping);
392
+
393
+ console.log(`\n✅ Epic sync complete!`);
394
+ console.log(` Epic: #${epicIssueNumber}`);
395
+ console.log(` Tasks: ${taskMapping.length} created and synced`);
396
+
397
+ return { epicIssueNumber, taskMapping };
398
+ } catch (error) {
399
+ console.error(`\n❌ Epic sync failed: ${error.message}`);
400
+ process.exit(1);
401
+ }
402
+ }
403
+
404
+ /**
405
+ * CLI interface
406
+ */
407
+ function main() {
408
+ const args = process.argv.slice(2);
409
+ const command = args[0];
410
+
411
+ if (!command) {
412
+ console.log('Usage:');
413
+ console.log(' epicSync.js sync <epic-path> - Full sync workflow');
414
+ console.log(' epicSync.js create-epic <epic-path> - Create epic issue only');
415
+ console.log(' epicSync.js create-tasks <epic-path> <epic-number> - Create task issues only');
416
+ console.log(' epicSync.js update-epic <epic-path> <epic-number> - Update epic file only');
417
+ console.log('');
418
+ console.log('Examples:');
419
+ console.log(' epicSync.js sync fullstack/01-infrastructure');
420
+ console.log(' epicSync.js create-epic fullstack/01-infrastructure');
421
+ process.exit(1);
422
+ }
423
+
424
+ switch (command) {
425
+ case 'sync': {
426
+ const epicPath = args[1];
427
+ if (!epicPath) {
428
+ console.error('Error: epic-path required');
429
+ process.exit(1);
430
+ }
431
+ syncEpic(epicPath);
432
+ break;
433
+ }
434
+
435
+ case 'create-epic': {
436
+ const epicPath = args[1];
437
+ if (!epicPath) {
438
+ console.error('Error: epic-path required');
439
+ process.exit(1);
440
+ }
441
+ const epicNumber = createEpicIssue(epicPath);
442
+ console.log(`\nEpic issue number: ${epicNumber}`);
443
+ break;
444
+ }
445
+
446
+ case 'create-tasks': {
447
+ const epicPath = args[1];
448
+ const epicNumber = parseInt(args[2]);
449
+ if (!epicPath || !epicNumber) {
450
+ console.error('Error: epic-path and epic-number required');
451
+ process.exit(1);
452
+ }
453
+ createTaskIssues(epicPath, epicNumber);
454
+ break;
455
+ }
456
+
457
+ case 'update-epic': {
458
+ const epicPath = args[1];
459
+ const epicNumber = parseInt(args[2]);
460
+ if (!epicPath || !epicNumber) {
461
+ console.error('Error: epic-path and epic-number required');
462
+ process.exit(1);
463
+ }
464
+ // Load mapping file
465
+ const mappingFile = path.join(process.cwd(), '.claude/epics', epicPath, '.task-mapping.txt');
466
+ let mapping = [];
467
+ if (fs.existsSync(mappingFile)) {
468
+ const content = fs.readFileSync(mappingFile, 'utf8');
469
+ mapping = content.split('\n')
470
+ .filter(line => line.trim())
471
+ .map(line => {
472
+ const [oldName, newNumber] = line.split(' ');
473
+ return { oldName, newNumber: parseInt(newNumber) };
474
+ });
475
+ }
476
+ updateEpicFile(epicPath, epicNumber, mapping);
477
+ break;
478
+ }
479
+
480
+ default:
481
+ console.error(`Unknown command: ${command}`);
482
+ process.exit(1);
483
+ }
484
+ }
485
+
486
+ if (require.main === module) {
487
+ main();
488
+ }
489
+
490
+ module.exports = {
491
+ parseMarkdownFile,
492
+ updateFrontmatter,
493
+ createEpicIssue,
494
+ createTaskIssues,
495
+ updateEpicFile,
496
+ updateTaskReferences,
497
+ syncEpic
498
+ };
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Epic Sync Tasks - Create GitHub issues for all tasks in an epic
4
+ *
5
+ * Modern Node.js replacement for bash scripts that had parsing issues.
6
+ * Uses simple, testable code instead of complex shell heredocs.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { execSync } = require('child_process');
12
+
13
+ /**
14
+ * Parse task file frontmatter and content
15
+ */
16
+ function parseTaskFile(filePath) {
17
+ const content = fs.readFileSync(filePath, 'utf8');
18
+ const lines = content.split('\n');
19
+
20
+ let inFrontmatter = false;
21
+ let frontmatterLines = [];
22
+ let bodyLines = [];
23
+ let frontmatterCount = 0;
24
+
25
+ for (const line of lines) {
26
+ if (line === '---') {
27
+ frontmatterCount++;
28
+ if (frontmatterCount === 1) {
29
+ inFrontmatter = true;
30
+ continue;
31
+ } else if (frontmatterCount === 2) {
32
+ inFrontmatter = false;
33
+ continue;
34
+ }
35
+ }
36
+
37
+ if (inFrontmatter) {
38
+ frontmatterLines.push(line);
39
+ } else if (frontmatterCount === 2) {
40
+ bodyLines.push(line);
41
+ }
42
+ }
43
+
44
+ // Parse frontmatter
45
+ const frontmatter = {};
46
+ for (const line of frontmatterLines) {
47
+ const match = line.match(/^(\w+):\s*(.+)$/);
48
+ if (match) {
49
+ const [, key, value] = match;
50
+ frontmatter[key] = value;
51
+ }
52
+ }
53
+
54
+ return {
55
+ frontmatter,
56
+ body: bodyLines.join('\n').trim(),
57
+ title: frontmatter.name || 'Untitled Task'
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Create GitHub issue for a task
63
+ */
64
+ function createTaskIssue(taskData, epicIssueNumber, epicPath, taskNumber) {
65
+ const { title, body } = taskData;
66
+
67
+ // Create issue body with epic reference
68
+ const issueBody = `
69
+ Part of Epic #${epicIssueNumber}
70
+
71
+ ---
72
+
73
+ ${body}
74
+
75
+ ---
76
+
77
+ **Epic Path:** \`${epicPath}\`
78
+ **Task Number:** ${taskNumber}
79
+ `.trim();
80
+
81
+ // Escape quotes for shell
82
+ const escapedTitle = title.replace(/"/g, '\\"');
83
+ const escapedBody = issueBody.replace(/"/g, '\\"').replace(/`/g, '\\`');
84
+
85
+ // Create GitHub issue
86
+ try {
87
+ const result = execSync(
88
+ `gh issue create --title "${escapedTitle}" --body "${escapedBody}" --label "task"`,
89
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
90
+ );
91
+
92
+ const issueMatch = result.match(/https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)/);
93
+ if (issueMatch) {
94
+ return parseInt(issueMatch[1]);
95
+ }
96
+
97
+ return null;
98
+ } catch (error) {
99
+ console.error(`❌ Failed to create issue for task ${taskNumber}:`, error.message);
100
+ return null;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Update task file with GitHub issue number
106
+ */
107
+ function updateTaskFrontmatter(filePath, issueNumber) {
108
+ const content = fs.readFileSync(filePath, 'utf8');
109
+ const updatedContent = content.replace(
110
+ /^github:.*$/m,
111
+ `github: "#${issueNumber}"`
112
+ );
113
+ fs.writeFileSync(filePath, updatedContent, 'utf8');
114
+ }
115
+
116
+ /**
117
+ * Main function
118
+ */
119
+ function main() {
120
+ const args = process.argv.slice(2);
121
+
122
+ if (args.length < 2) {
123
+ console.error('Usage: epicSyncTasks.js <epic-path> <epic-issue-number>');
124
+ console.error('Example: epicSyncTasks.js fullstack/01-infrastructure 2');
125
+ process.exit(1);
126
+ }
127
+
128
+ const [epicPath, epicIssueNumber] = args;
129
+ const epicDir = path.join(process.cwd(), '.claude/epics', epicPath);
130
+
131
+ if (!fs.existsSync(epicDir)) {
132
+ console.error(`❌ Epic directory not found: ${epicDir}`);
133
+ process.exit(1);
134
+ }
135
+
136
+ // Find all task files (numbered .md files)
137
+ const taskFiles = fs.readdirSync(epicDir)
138
+ .filter(f => /^\d+\.md$/.test(f))
139
+ .sort();
140
+
141
+ if (taskFiles.length === 0) {
142
+ console.error(`❌ No task files found in ${epicDir}`);
143
+ process.exit(1);
144
+ }
145
+
146
+ console.log(`📋 Creating ${taskFiles.length} task issues for epic #${epicIssueNumber}`);
147
+ console.log(`📂 Epic path: ${epicPath}\n`);
148
+
149
+ let successCount = 0;
150
+ let failCount = 0;
151
+
152
+ for (const taskFile of taskFiles) {
153
+ const taskNumber = taskFile.replace('.md', '');
154
+ const taskPath = path.join(epicDir, taskFile);
155
+
156
+ console.log(`[${successCount + failCount + 1}/${taskFiles.length}] Creating issue for task ${taskNumber}...`);
157
+
158
+ try {
159
+ const taskData = parseTaskFile(taskPath);
160
+ const issueNumber = createTaskIssue(taskData, epicIssueNumber, epicPath, taskNumber);
161
+
162
+ if (issueNumber) {
163
+ updateTaskFrontmatter(taskPath, issueNumber);
164
+ console.log(` ✅ Created issue #${issueNumber}: ${taskData.title}`);
165
+ successCount++;
166
+ } else {
167
+ console.log(` ⚠️ Issue created but number not found`);
168
+ failCount++;
169
+ }
170
+ } catch (error) {
171
+ console.error(` ❌ Error: ${error.message}`);
172
+ failCount++;
173
+ }
174
+ }
175
+
176
+ console.log(`\n📊 Summary:`);
177
+ console.log(` ✅ Success: ${successCount}`);
178
+ console.log(` ❌ Failed: ${failCount}`);
179
+ console.log(` 📝 Total: ${taskFiles.length}`);
180
+
181
+ if (failCount > 0) {
182
+ process.exit(1);
183
+ }
184
+ }
185
+
186
+ if (require.main === module) {
187
+ main();
188
+ }
189
+
190
+ module.exports = { parseTaskFile, createTaskIssue, updateTaskFrontmatter };
@@ -0,0 +1,593 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Issue Sync - Complete GitHub issue synchronization
4
+ *
5
+ * Replaces 5 Bash scripts with clean, testable JavaScript:
6
+ * - gather-updates.sh → gatherUpdates()
7
+ * - format-comment.sh → formatComment()
8
+ * - post-comment.sh → postComment()
9
+ * - update-frontmatter.sh → updateFrontmatter()
10
+ * - preflight-validation.sh → preflightValidation()
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { execSync } = require('child_process');
16
+
17
+ /**
18
+ * Parse frontmatter from markdown file
19
+ */
20
+ function parseFrontmatter(filePath) {
21
+ if (!fs.existsSync(filePath)) {
22
+ return null;
23
+ }
24
+
25
+ const content = fs.readFileSync(filePath, 'utf8');
26
+ const lines = content.split('\n');
27
+
28
+ let inFrontmatter = false;
29
+ let frontmatterCount = 0;
30
+ const frontmatter = {};
31
+
32
+ for (const line of lines) {
33
+ if (line === '---') {
34
+ frontmatterCount++;
35
+ if (frontmatterCount === 1) {
36
+ inFrontmatter = true;
37
+ } else if (frontmatterCount === 2) {
38
+ break;
39
+ }
40
+ continue;
41
+ }
42
+
43
+ if (inFrontmatter) {
44
+ const match = line.match(/^(\w+):\s*(.+)$/);
45
+ if (match) {
46
+ const [, key, value] = match;
47
+ frontmatter[key] = value;
48
+ }
49
+ }
50
+ }
51
+
52
+ return frontmatter;
53
+ }
54
+
55
+ /**
56
+ * Update frontmatter field
57
+ */
58
+ function updateFrontmatterField(filePath, field, value) {
59
+ const content = fs.readFileSync(filePath, 'utf8');
60
+ const lines = content.split('\n');
61
+ const result = [];
62
+
63
+ let inFrontmatter = false;
64
+ let frontmatterCount = 0;
65
+ let fieldUpdated = false;
66
+
67
+ for (const line of lines) {
68
+ if (line === '---') {
69
+ frontmatterCount++;
70
+ result.push(line);
71
+ if (frontmatterCount === 1) {
72
+ inFrontmatter = true;
73
+ } else if (frontmatterCount === 2) {
74
+ inFrontmatter = false;
75
+ // Add field if not found
76
+ if (!fieldUpdated && inFrontmatter === false) {
77
+ result.splice(result.length - 1, 0, `${field}: ${value}`);
78
+ }
79
+ }
80
+ continue;
81
+ }
82
+
83
+ if (inFrontmatter) {
84
+ const match = line.match(/^(\w+):/);
85
+ if (match && match[1] === field) {
86
+ result.push(`${field}: ${value}`);
87
+ fieldUpdated = true;
88
+ } else {
89
+ result.push(line);
90
+ }
91
+ } else {
92
+ result.push(line);
93
+ }
94
+ }
95
+
96
+ fs.writeFileSync(filePath, result.join('\n'), 'utf8');
97
+ return true;
98
+ }
99
+
100
+ /**
101
+ * Get current ISO timestamp
102
+ */
103
+ function getTimestamp() {
104
+ return new Date().toISOString();
105
+ }
106
+
107
+ /**
108
+ * Strip frontmatter and return body only
109
+ */
110
+ function stripFrontmatter(filePath) {
111
+ if (!fs.existsSync(filePath)) {
112
+ return '';
113
+ }
114
+
115
+ const content = fs.readFileSync(filePath, 'utf8');
116
+ const lines = content.split('\n');
117
+
118
+ let frontmatterCount = 0;
119
+ const bodyLines = [];
120
+
121
+ for (const line of lines) {
122
+ if (line === '---') {
123
+ frontmatterCount++;
124
+ continue;
125
+ }
126
+
127
+ if (frontmatterCount >= 2) {
128
+ bodyLines.push(line);
129
+ }
130
+ }
131
+
132
+ return bodyLines.join('\n').trim();
133
+ }
134
+
135
+ /**
136
+ * Gather updates from various sources
137
+ */
138
+ function gatherUpdates(issueNumber, updatesDir, lastSync = null) {
139
+ console.log(`\n📋 Gathering updates for issue #${issueNumber}`);
140
+
141
+ if (!fs.existsSync(updatesDir)) {
142
+ throw new Error(`Updates directory not found: ${updatesDir}`);
143
+ }
144
+
145
+ const updates = {
146
+ progress: '',
147
+ notes: '',
148
+ commits: '',
149
+ acceptanceCriteria: '',
150
+ nextSteps: '',
151
+ blockers: ''
152
+ };
153
+
154
+ // Gather progress
155
+ const progressFile = path.join(updatesDir, 'progress.md');
156
+ if (fs.existsSync(progressFile)) {
157
+ updates.progress = stripFrontmatter(progressFile);
158
+ const frontmatter = parseFrontmatter(progressFile);
159
+ if (frontmatter && frontmatter.completion) {
160
+ updates.progress += `\n\n**Current Progress:** ${frontmatter.completion}`;
161
+ }
162
+ }
163
+
164
+ // Gather notes
165
+ const notesFile = path.join(updatesDir, 'notes.md');
166
+ if (fs.existsSync(notesFile)) {
167
+ updates.notes = stripFrontmatter(notesFile);
168
+ }
169
+
170
+ // Gather commits
171
+ const commitsFile = path.join(updatesDir, 'commits.md');
172
+ if (fs.existsSync(commitsFile)) {
173
+ updates.commits = stripFrontmatter(commitsFile);
174
+ } else {
175
+ // Auto-gather recent commits
176
+ updates.commits = gatherRecentCommits(lastSync);
177
+ }
178
+
179
+ // Gather acceptance criteria
180
+ const criteriaFile = path.join(updatesDir, 'acceptance-criteria.md');
181
+ if (fs.existsSync(criteriaFile)) {
182
+ updates.acceptanceCriteria = stripFrontmatter(criteriaFile);
183
+ }
184
+
185
+ // Gather next steps
186
+ const nextStepsFile = path.join(updatesDir, 'next-steps.md');
187
+ if (fs.existsSync(nextStepsFile)) {
188
+ updates.nextSteps = stripFrontmatter(nextStepsFile);
189
+ }
190
+
191
+ // Gather blockers
192
+ const blockersFile = path.join(updatesDir, 'blockers.md');
193
+ if (fs.existsSync(blockersFile)) {
194
+ updates.blockers = stripFrontmatter(blockersFile);
195
+ }
196
+
197
+ console.log('✅ Updates gathered');
198
+ return updates;
199
+ }
200
+
201
+ /**
202
+ * Auto-gather recent commits
203
+ */
204
+ function gatherRecentCommits(since = null) {
205
+ try {
206
+ const sinceArg = since || '24 hours ago';
207
+ const result = execSync(
208
+ `git log --since="${sinceArg}" --oneline --no-merges`,
209
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
210
+ );
211
+
212
+ if (result.trim()) {
213
+ const commits = result.trim().split('\n').map(line => `- ${line}`);
214
+ return `**Recent Commits:**\n${commits.join('\n')}`;
215
+ }
216
+ } catch (error) {
217
+ // Git not available or no commits
218
+ }
219
+
220
+ return 'No recent commits found';
221
+ }
222
+
223
+ /**
224
+ * Format comment from gathered updates
225
+ */
226
+ function formatComment(issueNumber, updates, isCompletion = false) {
227
+ console.log(`\n📝 Formatting comment for issue #${issueNumber}`);
228
+
229
+ const sections = [];
230
+
231
+ if (isCompletion) {
232
+ sections.push('# ✅ Task Completed\n');
233
+ sections.push(`*Completed at: ${getTimestamp()}*\n`);
234
+ } else {
235
+ sections.push('# 📊 Progress Update\n');
236
+ sections.push(`*Updated at: ${getTimestamp()}*\n`);
237
+ }
238
+
239
+ // Add sections with content
240
+ if (updates.progress && !updates.progress.includes('No progress')) {
241
+ sections.push('## Progress Updates\n');
242
+ sections.push(updates.progress + '\n');
243
+ }
244
+
245
+ if (updates.notes && !updates.notes.includes('No technical notes')) {
246
+ sections.push('## Technical Notes\n');
247
+ sections.push(updates.notes + '\n');
248
+ }
249
+
250
+ if (updates.commits && !updates.commits.includes('No recent commits')) {
251
+ sections.push('## Recent Commits\n');
252
+ sections.push(updates.commits + '\n');
253
+ }
254
+
255
+ if (updates.acceptanceCriteria && !updates.acceptanceCriteria.includes('No acceptance')) {
256
+ sections.push('## Acceptance Criteria\n');
257
+ sections.push(updates.acceptanceCriteria + '\n');
258
+ }
259
+
260
+ if (updates.nextSteps && !updates.nextSteps.includes('No specific next steps')) {
261
+ sections.push('## Next Steps\n');
262
+ sections.push(updates.nextSteps + '\n');
263
+ }
264
+
265
+ if (updates.blockers && !updates.blockers.includes('No current blockers')) {
266
+ sections.push('## Blockers\n');
267
+ sections.push(updates.blockers + '\n');
268
+ }
269
+
270
+ const comment = sections.join('\n');
271
+ console.log('✅ Comment formatted');
272
+
273
+ return comment;
274
+ }
275
+
276
+ /**
277
+ * Post comment to GitHub issue
278
+ */
279
+ function postComment(issueNumber, comment, isDryRun = false) {
280
+ console.log(`\n💬 Posting comment to issue #${issueNumber}`);
281
+
282
+ if (isDryRun) {
283
+ console.log('🔸 DRY RUN - Comment preview:');
284
+ console.log(comment.split('\n').slice(0, 20).join('\n'));
285
+ console.log('...');
286
+ return 'https://github.com/DRYRUN/issues/' + issueNumber + '#issuecomment-DRYRUN';
287
+ }
288
+
289
+ // Write comment to temp file
290
+ const tempFile = `/tmp/issue-comment-${issueNumber}-${Date.now()}.md`;
291
+ fs.writeFileSync(tempFile, comment, 'utf8');
292
+
293
+ try {
294
+ // Post via gh CLI
295
+ const result = execSync(
296
+ `gh issue comment ${issueNumber} --body-file "${tempFile}"`,
297
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
298
+ );
299
+
300
+ // Extract URL from result
301
+ const match = result.match(/https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+#issuecomment-\d+/);
302
+ if (match) {
303
+ const url = match[0];
304
+ console.log(`✅ Comment posted: ${url}`);
305
+
306
+ // Cleanup temp file
307
+ fs.unlinkSync(tempFile);
308
+
309
+ return url;
310
+ }
311
+
312
+ console.log('⚠️ Comment posted but URL not found in response');
313
+ return null;
314
+ } catch (error) {
315
+ console.error(`❌ Failed to post comment: ${error.message}`);
316
+ console.log(`Temp comment file preserved: ${tempFile}`);
317
+ throw error;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Update frontmatter after successful sync
323
+ */
324
+ function updateFrontmatterAfterSync(progressFile, commentUrl, isCompletion = false) {
325
+ console.log(`\n📄 Updating frontmatter: ${progressFile}`);
326
+
327
+ if (!fs.existsSync(progressFile)) {
328
+ throw new Error(`Progress file not found: ${progressFile}`);
329
+ }
330
+
331
+ // Create backup
332
+ const backupFile = `${progressFile}.backup.${Date.now()}`;
333
+ fs.copyFileSync(progressFile, backupFile);
334
+
335
+ try {
336
+ // Update last_sync
337
+ updateFrontmatterField(progressFile, 'last_sync', getTimestamp());
338
+
339
+ // Update comment URL if provided
340
+ if (commentUrl) {
341
+ updateFrontmatterField(progressFile, 'last_comment_url', commentUrl);
342
+ }
343
+
344
+ // Update completion if needed
345
+ if (isCompletion) {
346
+ updateFrontmatterField(progressFile, 'completion', '100');
347
+ updateFrontmatterField(progressFile, 'status', 'completed');
348
+ updateFrontmatterField(progressFile, 'completed_at', getTimestamp());
349
+ }
350
+
351
+ // Update issue state from GitHub
352
+ try {
353
+ const issueState = execSync('gh issue view --json state -q .state', {
354
+ encoding: 'utf8',
355
+ stdio: ['pipe', 'pipe', 'pipe']
356
+ }).trim();
357
+
358
+ if (issueState) {
359
+ updateFrontmatterField(progressFile, 'issue_state', issueState);
360
+ }
361
+ } catch (error) {
362
+ // GitHub CLI not available or issue not found
363
+ }
364
+
365
+ console.log('✅ Frontmatter updated');
366
+
367
+ // Cleanup old backups (keep last 5)
368
+ cleanupOldBackups(progressFile, 5);
369
+
370
+ } catch (error) {
371
+ console.error('❌ Frontmatter update failed, restoring backup');
372
+ fs.copyFileSync(backupFile, progressFile);
373
+ throw error;
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Cleanup old backup files
379
+ */
380
+ function cleanupOldBackups(originalFile, keepCount = 5) {
381
+ const dir = path.dirname(originalFile);
382
+ const basename = path.basename(originalFile);
383
+
384
+ try {
385
+ const backups = fs.readdirSync(dir)
386
+ .filter(f => f.startsWith(basename + '.backup.'))
387
+ .map(f => ({
388
+ name: f,
389
+ path: path.join(dir, f),
390
+ time: fs.statSync(path.join(dir, f)).mtime.getTime()
391
+ }))
392
+ .sort((a, b) => b.time - a.time);
393
+
394
+ // Remove old backups
395
+ for (let i = keepCount; i < backups.length; i++) {
396
+ fs.unlinkSync(backups[i].path);
397
+ }
398
+ } catch (error) {
399
+ // Ignore cleanup errors
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Preflight validation
405
+ */
406
+ function preflightValidation(issueNumber, updatesDir) {
407
+ console.log(`\n🔍 Preflight validation for issue #${issueNumber}`);
408
+
409
+ const errors = [];
410
+
411
+ // Check GitHub auth
412
+ try {
413
+ execSync('gh auth status', { stdio: ['pipe', 'pipe', 'pipe'] });
414
+ } catch (error) {
415
+ errors.push('GitHub CLI not authenticated. Run: gh auth login');
416
+ }
417
+
418
+ // Check if issue exists
419
+ try {
420
+ const state = execSync(`gh issue view ${issueNumber} --json state -q .state`, {
421
+ encoding: 'utf8',
422
+ stdio: ['pipe', 'pipe', 'pipe']
423
+ }).trim();
424
+
425
+ if (state === 'CLOSED') {
426
+ console.log(`⚠️ Issue #${issueNumber} is closed`);
427
+ }
428
+ } catch (error) {
429
+ errors.push(`Issue #${issueNumber} not found`);
430
+ }
431
+
432
+ // Check updates directory
433
+ if (!fs.existsSync(updatesDir)) {
434
+ errors.push(`Updates directory not found: ${updatesDir}`);
435
+ }
436
+
437
+ if (errors.length > 0) {
438
+ console.error('\n❌ Preflight validation failed:');
439
+ errors.forEach(err => console.error(` - ${err}`));
440
+ return false;
441
+ }
442
+
443
+ console.log('✅ Preflight validation passed');
444
+ return true;
445
+ }
446
+
447
+ /**
448
+ * Full sync workflow
449
+ */
450
+ function syncIssue(issueNumber, updatesDir, isCompletion = false, isDryRun = false) {
451
+ console.log(`\n🚀 Starting issue sync: #${issueNumber}`);
452
+
453
+ // Preflight validation
454
+ if (!preflightValidation(issueNumber, updatesDir)) {
455
+ throw new Error('Preflight validation failed');
456
+ }
457
+
458
+ // Gather updates
459
+ const progressFile = path.join(updatesDir, 'progress.md');
460
+ const frontmatter = parseFrontmatter(progressFile);
461
+ const lastSync = frontmatter ? frontmatter.last_sync : null;
462
+
463
+ const updates = gatherUpdates(issueNumber, updatesDir, lastSync);
464
+
465
+ // Format comment
466
+ const comment = formatComment(issueNumber, updates, isCompletion);
467
+
468
+ // Post comment
469
+ const commentUrl = postComment(issueNumber, comment, isDryRun);
470
+
471
+ // Update frontmatter
472
+ if (!isDryRun && fs.existsSync(progressFile)) {
473
+ updateFrontmatterAfterSync(progressFile, commentUrl, isCompletion);
474
+ }
475
+
476
+ console.log(`\n✅ Issue sync complete!`);
477
+ console.log(` Issue: #${issueNumber}`);
478
+ if (commentUrl) {
479
+ console.log(` Comment: ${commentUrl}`);
480
+ }
481
+
482
+ return { commentUrl, updates };
483
+ }
484
+
485
+ /**
486
+ * CLI interface
487
+ */
488
+ function main() {
489
+ const args = process.argv.slice(2);
490
+ const command = args[0];
491
+
492
+ if (!command) {
493
+ console.log('Usage:');
494
+ console.log(' issueSync.js sync <issue-number> <updates-dir> [--complete] [--dry-run]');
495
+ console.log(' issueSync.js gather <issue-number> <updates-dir>');
496
+ console.log(' issueSync.js format <issue-number> <updates-dir> [--complete]');
497
+ console.log(' issueSync.js post <issue-number> <comment-file> [--dry-run]');
498
+ console.log(' issueSync.js update <progress-file> <comment-url> [--complete]');
499
+ console.log('');
500
+ console.log('Examples:');
501
+ console.log(' issueSync.js sync 123 .claude/epics/auth/updates/123');
502
+ console.log(' issueSync.js sync 456 ./updates --complete');
503
+ console.log(' issueSync.js sync 789 ./updates --dry-run');
504
+ process.exit(1);
505
+ }
506
+
507
+ const issueNumber = args[1];
508
+ const isComplete = args.includes('--complete');
509
+ const isDryRun = args.includes('--dry-run');
510
+
511
+ try {
512
+ switch (command) {
513
+ case 'sync': {
514
+ const updatesDir = args[2];
515
+ if (!issueNumber || !updatesDir) {
516
+ console.error('Error: issue-number and updates-dir required');
517
+ process.exit(1);
518
+ }
519
+ syncIssue(issueNumber, updatesDir, isComplete, isDryRun);
520
+ break;
521
+ }
522
+
523
+ case 'gather': {
524
+ const updatesDir = args[2];
525
+ if (!issueNumber || !updatesDir) {
526
+ console.error('Error: issue-number and updates-dir required');
527
+ process.exit(1);
528
+ }
529
+ const updates = gatherUpdates(issueNumber, updatesDir);
530
+ console.log(JSON.stringify(updates, null, 2));
531
+ break;
532
+ }
533
+
534
+ case 'format': {
535
+ const updatesDir = args[2];
536
+ if (!issueNumber || !updatesDir) {
537
+ console.error('Error: issue-number and updates-dir required');
538
+ process.exit(1);
539
+ }
540
+ const updates = gatherUpdates(issueNumber, updatesDir);
541
+ const comment = formatComment(issueNumber, updates, isComplete);
542
+ console.log(comment);
543
+ break;
544
+ }
545
+
546
+ case 'post': {
547
+ const commentFile = args[2];
548
+ if (!issueNumber || !commentFile) {
549
+ console.error('Error: issue-number and comment-file required');
550
+ process.exit(1);
551
+ }
552
+ const comment = fs.readFileSync(commentFile, 'utf8');
553
+ const url = postComment(issueNumber, comment, isDryRun);
554
+ console.log(url);
555
+ break;
556
+ }
557
+
558
+ case 'update': {
559
+ const progressFile = args[2];
560
+ const commentUrl = args[3];
561
+ if (!progressFile) {
562
+ console.error('Error: progress-file required');
563
+ process.exit(1);
564
+ }
565
+ updateFrontmatterAfterSync(progressFile, commentUrl, isComplete);
566
+ break;
567
+ }
568
+
569
+ default:
570
+ console.error(`Unknown command: ${command}`);
571
+ process.exit(1);
572
+ }
573
+ } catch (error) {
574
+ console.error(`\n❌ Error: ${error.message}`);
575
+ process.exit(1);
576
+ }
577
+ }
578
+
579
+ if (require.main === module) {
580
+ main();
581
+ }
582
+
583
+ module.exports = {
584
+ parseFrontmatter,
585
+ updateFrontmatterField,
586
+ stripFrontmatter,
587
+ gatherUpdates,
588
+ formatComment,
589
+ postComment,
590
+ updateFrontmatterAfterSync,
591
+ preflightValidation,
592
+ syncIssue
593
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-autopm",
3
- "version": "1.24.0",
3
+ "version": "1.25.0",
4
4
  "description": "Autonomous Project Management Framework for Claude Code - Advanced AI-powered development automation",
5
5
  "main": "bin/autopm.js",
6
6
  "bin": {