specweave 0.17.17 → 0.17.19
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.
- package/dist/plugins/specweave-ado/lib/ado-spec-content-sync.d.ts.map +1 -1
- package/dist/plugins/specweave-ado/lib/ado-spec-content-sync.js +65 -6
- package/dist/plugins/specweave-ado/lib/ado-spec-content-sync.js.map +1 -1
- package/dist/plugins/specweave-github/lib/epic-content-builder.d.ts +63 -0
- package/dist/plugins/specweave-github/lib/epic-content-builder.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/epic-content-builder.js +216 -0
- package/dist/plugins/specweave-github/lib/epic-content-builder.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-epic-sync.d.ts +2 -2
- package/dist/plugins/specweave-github/lib/github-epic-sync.d.ts.map +1 -1
- package/dist/plugins/specweave-github/lib/github-epic-sync.js +19 -4
- package/dist/plugins/specweave-github/lib/github-epic-sync.js.map +1 -1
- package/dist/plugins/specweave-jira/lib/enhanced-jira-sync.d.ts +8 -6
- package/dist/plugins/specweave-jira/lib/enhanced-jira-sync.d.ts.map +1 -1
- package/dist/plugins/specweave-jira/lib/enhanced-jira-sync.js +78 -117
- package/dist/plugins/specweave-jira/lib/enhanced-jira-sync.js.map +1 -1
- package/dist/src/cli/commands/init.d.ts.map +1 -1
- package/dist/src/cli/commands/init.js +107 -3
- package/dist/src/cli/commands/init.js.map +1 -1
- package/dist/src/core/deduplication/command-deduplicator.d.ts +166 -0
- package/dist/src/core/deduplication/command-deduplicator.d.ts.map +1 -0
- package/dist/src/core/deduplication/command-deduplicator.js +254 -0
- package/dist/src/core/deduplication/command-deduplicator.js.map +1 -0
- package/dist/src/core/sync/enhanced-content-builder.d.ts +32 -54
- package/dist/src/core/sync/enhanced-content-builder.d.ts.map +1 -1
- package/dist/src/core/sync/enhanced-content-builder.js +141 -138
- package/dist/src/core/sync/enhanced-content-builder.js.map +1 -1
- package/dist/src/core/sync/types.d.ts +52 -0
- package/dist/src/core/sync/types.d.ts.map +1 -0
- package/dist/src/core/sync/types.js +5 -0
- package/dist/src/core/sync/types.js.map +1 -0
- package/dist/src/core/types/config.d.ts +51 -0
- package/dist/src/core/types/config.d.ts.map +1 -1
- package/dist/src/core/types/config.js +16 -0
- package/dist/src/core/types/config.js.map +1 -1
- package/dist/src/core/types/increment-metadata.d.ts +4 -0
- package/dist/src/core/types/increment-metadata.d.ts.map +1 -1
- package/dist/src/core/types/increment-metadata.js.map +1 -1
- package/package.json +1 -1
- package/plugins/specweave/agents/pm/AGENT.md +159 -12
- package/plugins/specweave/commands/specweave.md +70 -405
- package/plugins/specweave/hooks/hooks.json +4 -0
- package/plugins/specweave/hooks/lib/sync-spec-content.sh +2 -2
- package/plugins/specweave/hooks/post-increment-planning.sh +26 -2
- package/plugins/specweave/hooks/pre-command-deduplication.sh +86 -0
- package/plugins/specweave-ado/commands/specweave-ado-sync-spec.md +1 -1
- package/plugins/specweave-ado/lib/ado-spec-content-sync.js +49 -5
- package/plugins/specweave-ado/lib/ado-spec-content-sync.ts +72 -6
- package/plugins/specweave-github/commands/specweave-github-cleanup-duplicates.md +1 -1
- package/plugins/specweave-github/commands/specweave-github-sync-epic.md +1 -1
- package/plugins/specweave-github/commands/specweave-github-sync-spec.md +1 -1
- package/plugins/specweave-github/hooks/post-task-completion.sh +32 -0
- package/plugins/specweave-github/lib/epic-content-builder.js +227 -0
- package/plugins/specweave-github/lib/epic-content-builder.ts +317 -0
- package/plugins/specweave-github/lib/github-epic-sync.js +23 -24
- package/plugins/specweave-github/lib/github-epic-sync.ts +29 -4
- package/plugins/specweave-jira/commands/specweave-jira-sync-epic.md +1 -1
- package/plugins/specweave-jira/commands/specweave-jira-sync-spec.md +1 -1
- package/plugins/specweave-jira/lib/enhanced-jira-sync.js +134 -0
- package/plugins/specweave-jira/lib/{enhanced-jira-sync.ts.disabled → enhanced-jira-sync.ts} +26 -52
- package/plugins/specweave-release/commands/specweave-release-platform.md +1 -1
- package/plugins/specweave-release/hooks/post-task-completion.sh +2 -2
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# SpecWeave Pre-Command Deduplication Hook
|
|
4
|
+
# Fires BEFORE any command executes (UserPromptSubmit hook)
|
|
5
|
+
# Purpose: Prevent duplicate command invocations within configurable time window
|
|
6
|
+
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
# Read input JSON from stdin
|
|
10
|
+
INPUT=$(cat)
|
|
11
|
+
|
|
12
|
+
# Extract prompt from JSON
|
|
13
|
+
PROMPT=$(echo "$INPUT" | node -e "
|
|
14
|
+
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
|
15
|
+
console.log(input.prompt || '');
|
|
16
|
+
")
|
|
17
|
+
|
|
18
|
+
# ==============================================================================
|
|
19
|
+
# DEDUPLICATION CHECK: Block duplicate commands within 1 second
|
|
20
|
+
# ==============================================================================
|
|
21
|
+
|
|
22
|
+
# Extract command name from prompt (if slash command)
|
|
23
|
+
COMMAND=$(echo "$PROMPT" | grep -oE "^/[a-z0-9:-]+" | head -1 || echo "")
|
|
24
|
+
|
|
25
|
+
if [[ -n "$COMMAND" ]]; then
|
|
26
|
+
# Check deduplication using TypeScript module
|
|
27
|
+
if command -v node >/dev/null 2>&1 && [[ -f "dist/src/core/deduplication/command-deduplicator.js" ]]; then
|
|
28
|
+
# Run deduplication check
|
|
29
|
+
DEDUP_RESULT=$(node -e "
|
|
30
|
+
(async () => {
|
|
31
|
+
try {
|
|
32
|
+
const { CommandDeduplicator } = require('./dist/src/core/deduplication/command-deduplicator.js');
|
|
33
|
+
const dedup = new CommandDeduplicator({ debug: false });
|
|
34
|
+
|
|
35
|
+
// Parse command and args
|
|
36
|
+
const fullCommand = '${COMMAND}';
|
|
37
|
+
const args = '${PROMPT}'.replace(fullCommand, '').trim().split(/\\s+/).filter(Boolean);
|
|
38
|
+
|
|
39
|
+
// Check for duplicate
|
|
40
|
+
const isDuplicate = await dedup.checkDuplicate(fullCommand, args);
|
|
41
|
+
|
|
42
|
+
if (isDuplicate) {
|
|
43
|
+
const stats = dedup.getStats();
|
|
44
|
+
console.log('DUPLICATE');
|
|
45
|
+
console.log(JSON.stringify(stats));
|
|
46
|
+
} else {
|
|
47
|
+
// Record invocation
|
|
48
|
+
await dedup.recordInvocation(fullCommand, args);
|
|
49
|
+
console.log('OK');
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
console.error('Error in deduplication:', e.message);
|
|
53
|
+
console.log('OK'); // Fail-open (don't block on errors)
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
56
|
+
" 2>/dev/null || echo "OK")
|
|
57
|
+
|
|
58
|
+
# Parse result
|
|
59
|
+
STATUS=$(echo "$DEDUP_RESULT" | head -1)
|
|
60
|
+
|
|
61
|
+
if [[ "$STATUS" == "DUPLICATE" ]]; then
|
|
62
|
+
# Get stats
|
|
63
|
+
STATS=$(echo "$DEDUP_RESULT" | tail -1)
|
|
64
|
+
|
|
65
|
+
cat <<EOF
|
|
66
|
+
{
|
|
67
|
+
"decision": "block",
|
|
68
|
+
"reason": "🚫 DUPLICATE COMMAND DETECTED\\n\\nCommand: \`$COMMAND\`\\nTime window: 1 second\\n\\nThis command was just executed! To prevent unintended duplicates, this invocation has been blocked.\\n\\n💡 If you meant to run this command again:\\n 1. Wait 1 second\\n 2. Run the command again\\n\\n📊 Deduplication Stats:\\n$STATS\\n\\n🔧 To adjust the time window, edit \`.specweave/config.json\`:\\n\`\`\`json\\n{\\n \\\"deduplication\\\": {\\n \\\"windowMs\\\": 2000 // Increase to 2 seconds\\n }\\n}\\n\`\`\`"
|
|
69
|
+
}
|
|
70
|
+
EOF
|
|
71
|
+
exit 0
|
|
72
|
+
fi
|
|
73
|
+
fi
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
# ==============================================================================
|
|
77
|
+
# PASS THROUGH: No duplicate detected, proceed with command
|
|
78
|
+
# ==============================================================================
|
|
79
|
+
|
|
80
|
+
cat <<EOF
|
|
81
|
+
{
|
|
82
|
+
"decision": "approve"
|
|
83
|
+
}
|
|
84
|
+
EOF
|
|
85
|
+
|
|
86
|
+
exit 0
|
|
@@ -4,8 +4,9 @@ import {
|
|
|
4
4
|
hasExternalLink,
|
|
5
5
|
updateSpecWithExternalLink
|
|
6
6
|
} from "../../../src/core/spec-content-sync.js";
|
|
7
|
-
import
|
|
8
|
-
import
|
|
7
|
+
import { SpecIncrementMapper } from "../../../src/core/sync/spec-increment-mapper.js";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
import * as fs from "fs/promises";
|
|
9
10
|
async function syncSpecContentToAdo(options) {
|
|
10
11
|
const { specPath, client, dryRun = false, verbose = false } = options;
|
|
11
12
|
try {
|
|
@@ -44,8 +45,9 @@ async function syncSpecContentToAdo(options) {
|
|
|
44
45
|
async function createAdoFeature(client, spec, options) {
|
|
45
46
|
const { specPath, dryRun, verbose } = options;
|
|
46
47
|
try {
|
|
48
|
+
const tasks = await getTaskMappings(specPath, spec.id);
|
|
47
49
|
const title = `[${spec.id.toUpperCase()}] ${spec.title}`;
|
|
48
|
-
const description = buildAdoDescription(spec);
|
|
50
|
+
const description = buildAdoDescription(spec, tasks);
|
|
49
51
|
if (verbose) {
|
|
50
52
|
console.log(`
|
|
51
53
|
\u{1F4DD} Creating ADO feature:`);
|
|
@@ -125,8 +127,9 @@ async function updateAdoFeature(client, spec, workItemId, options) {
|
|
|
125
127
|
console.log(` - ${change}`);
|
|
126
128
|
}
|
|
127
129
|
}
|
|
130
|
+
const tasks = await getTaskMappings(specPath, spec.id);
|
|
128
131
|
const newTitle = `[${spec.id.toUpperCase()}] ${spec.title}`;
|
|
129
|
-
const newDescription = buildAdoDescription(spec);
|
|
132
|
+
const newDescription = buildAdoDescription(spec, tasks);
|
|
130
133
|
if (dryRun) {
|
|
131
134
|
console.log("\n\u{1F50D} Dry run - would update feature:");
|
|
132
135
|
console.log(` Title: ${newTitle}`);
|
|
@@ -160,7 +163,7 @@ ${newDescription}`);
|
|
|
160
163
|
};
|
|
161
164
|
}
|
|
162
165
|
}
|
|
163
|
-
function buildAdoDescription(spec) {
|
|
166
|
+
function buildAdoDescription(spec, tasks) {
|
|
164
167
|
let html = "";
|
|
165
168
|
if (spec.description) {
|
|
166
169
|
html += `<p>${escapeHtml(spec.description)}</p>`;
|
|
@@ -179,11 +182,52 @@ function buildAdoDescription(spec) {
|
|
|
179
182
|
}
|
|
180
183
|
}
|
|
181
184
|
}
|
|
185
|
+
if (tasks && tasks.length > 0) {
|
|
186
|
+
html += "<h2>Implementation Tasks</h2>";
|
|
187
|
+
html += "<ul>";
|
|
188
|
+
for (const task of tasks) {
|
|
189
|
+
html += `<li><strong>${task.id}</strong>: ${escapeHtml(task.title)}`;
|
|
190
|
+
if (task.userStories && task.userStories.length > 0) {
|
|
191
|
+
html += ` (${task.userStories.join(", ")})`;
|
|
192
|
+
}
|
|
193
|
+
html += "</li>";
|
|
194
|
+
}
|
|
195
|
+
html += "</ul>";
|
|
196
|
+
}
|
|
182
197
|
if (spec.metadata.priority) {
|
|
183
198
|
html += `<p><strong>Priority:</strong> ${spec.metadata.priority}</p>`;
|
|
184
199
|
}
|
|
185
200
|
return html;
|
|
186
201
|
}
|
|
202
|
+
async function getTaskMappings(specPath, specId) {
|
|
203
|
+
try {
|
|
204
|
+
const rootDir = await findSpecWeaveRoot(specPath);
|
|
205
|
+
const mapper = new SpecIncrementMapper(rootDir);
|
|
206
|
+
const mapping = await mapper.mapSpecToIncrements(specId);
|
|
207
|
+
if (mapping.increments.length > 0) {
|
|
208
|
+
return mapping.increments[0].tasks;
|
|
209
|
+
}
|
|
210
|
+
return void 0;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
return void 0;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function findSpecWeaveRoot(specPath) {
|
|
216
|
+
let currentDir = path.dirname(specPath);
|
|
217
|
+
while (true) {
|
|
218
|
+
const specweaveDir = path.join(currentDir, ".specweave");
|
|
219
|
+
try {
|
|
220
|
+
await fs.access(specweaveDir);
|
|
221
|
+
return currentDir;
|
|
222
|
+
} catch {
|
|
223
|
+
const parentDir = path.dirname(currentDir);
|
|
224
|
+
if (parentDir === currentDir) {
|
|
225
|
+
throw new Error(".specweave directory not found");
|
|
226
|
+
}
|
|
227
|
+
currentDir = parentDir;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
187
231
|
function escapeHtml(text) {
|
|
188
232
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
189
233
|
}
|
|
@@ -20,8 +20,9 @@ import {
|
|
|
20
20
|
ContentSyncResult,
|
|
21
21
|
} from '../../../src/core/spec-content-sync.js';
|
|
22
22
|
import { SyncProfile } from '../../../src/core/types/sync-profile.js';
|
|
23
|
-
import
|
|
24
|
-
import
|
|
23
|
+
import { SpecIncrementMapper, TaskInfo } from '../../../src/core/sync/spec-increment-mapper.js';
|
|
24
|
+
import * as path from 'path';
|
|
25
|
+
import * as fs from 'fs/promises';
|
|
25
26
|
|
|
26
27
|
export interface AdoContentSyncOptions {
|
|
27
28
|
specPath: string;
|
|
@@ -91,9 +92,12 @@ async function createAdoFeature(
|
|
|
91
92
|
const { specPath, dryRun, verbose } = options;
|
|
92
93
|
|
|
93
94
|
try {
|
|
95
|
+
// Get task mappings (if available)
|
|
96
|
+
const tasks = await getTaskMappings(specPath, spec.id);
|
|
97
|
+
|
|
94
98
|
// Build feature title and description
|
|
95
99
|
const title = `[${spec.id.toUpperCase()}] ${spec.title}`;
|
|
96
|
-
const description = buildAdoDescription(spec);
|
|
100
|
+
const description = buildAdoDescription(spec, tasks);
|
|
97
101
|
|
|
98
102
|
if (verbose) {
|
|
99
103
|
console.log(`\n📝 Creating ADO feature:`);
|
|
@@ -196,9 +200,12 @@ async function updateAdoFeature(
|
|
|
196
200
|
}
|
|
197
201
|
}
|
|
198
202
|
|
|
203
|
+
// Get task mappings (if available)
|
|
204
|
+
const tasks = await getTaskMappings(specPath, spec.id);
|
|
205
|
+
|
|
199
206
|
// Build updated content
|
|
200
207
|
const newTitle = `[${spec.id.toUpperCase()}] ${spec.title}`;
|
|
201
|
-
const newDescription = buildAdoDescription(spec);
|
|
208
|
+
const newDescription = buildAdoDescription(spec, tasks);
|
|
202
209
|
|
|
203
210
|
if (dryRun) {
|
|
204
211
|
console.log('\n🔍 Dry run - would update feature:');
|
|
@@ -242,10 +249,10 @@ async function updateAdoFeature(
|
|
|
242
249
|
}
|
|
243
250
|
|
|
244
251
|
/**
|
|
245
|
-
* Build ADO description from spec content
|
|
252
|
+
* Build ADO description from spec content with optional task mappings
|
|
246
253
|
* ADO supports HTML in description
|
|
247
254
|
*/
|
|
248
|
-
function buildAdoDescription(spec: SpecContent): string {
|
|
255
|
+
function buildAdoDescription(spec: SpecContent, tasks?: TaskInfo[]): string {
|
|
249
256
|
let html = '';
|
|
250
257
|
|
|
251
258
|
// Add spec description
|
|
@@ -271,6 +278,20 @@ function buildAdoDescription(spec: SpecContent): string {
|
|
|
271
278
|
}
|
|
272
279
|
}
|
|
273
280
|
|
|
281
|
+
// Add task mappings (if provided)
|
|
282
|
+
if (tasks && tasks.length > 0) {
|
|
283
|
+
html += '<h2>Implementation Tasks</h2>';
|
|
284
|
+
html += '<ul>';
|
|
285
|
+
for (const task of tasks) {
|
|
286
|
+
html += `<li><strong>${task.id}</strong>: ${escapeHtml(task.title)}`;
|
|
287
|
+
if (task.userStories && task.userStories.length > 0) {
|
|
288
|
+
html += ` (${task.userStories.join(', ')})`;
|
|
289
|
+
}
|
|
290
|
+
html += '</li>';
|
|
291
|
+
}
|
|
292
|
+
html += '</ul>';
|
|
293
|
+
}
|
|
294
|
+
|
|
274
295
|
// Add metadata
|
|
275
296
|
if (spec.metadata.priority) {
|
|
276
297
|
html += `<p><strong>Priority:</strong> ${spec.metadata.priority}</p>`;
|
|
@@ -279,6 +300,51 @@ function buildAdoDescription(spec: SpecContent): string {
|
|
|
279
300
|
return html;
|
|
280
301
|
}
|
|
281
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Get task mappings for a spec (if available)
|
|
305
|
+
*/
|
|
306
|
+
async function getTaskMappings(specPath: string, specId: string): Promise<TaskInfo[] | undefined> {
|
|
307
|
+
try {
|
|
308
|
+
// Find SpecWeave root
|
|
309
|
+
const rootDir = await findSpecWeaveRoot(specPath);
|
|
310
|
+
|
|
311
|
+
// Use SpecIncrementMapper to get task mappings
|
|
312
|
+
const mapper = new SpecIncrementMapper(rootDir);
|
|
313
|
+
const mapping = await mapper.mapSpecToIncrements(specId);
|
|
314
|
+
|
|
315
|
+
if (mapping.increments.length > 0) {
|
|
316
|
+
// Return tasks from the first (most recent) increment
|
|
317
|
+
return mapping.increments[0].tasks;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return undefined;
|
|
321
|
+
} catch (error) {
|
|
322
|
+
// If mapping fails, just return undefined (not critical)
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Find SpecWeave root directory from spec path
|
|
329
|
+
*/
|
|
330
|
+
async function findSpecWeaveRoot(specPath: string): Promise<string> {
|
|
331
|
+
let currentDir = path.dirname(specPath);
|
|
332
|
+
|
|
333
|
+
while (true) {
|
|
334
|
+
const specweaveDir = path.join(currentDir, '.specweave');
|
|
335
|
+
try {
|
|
336
|
+
await fs.access(specweaveDir);
|
|
337
|
+
return currentDir;
|
|
338
|
+
} catch {
|
|
339
|
+
const parentDir = path.dirname(currentDir);
|
|
340
|
+
if (parentDir === currentDir) {
|
|
341
|
+
throw new Error('.specweave directory not found');
|
|
342
|
+
}
|
|
343
|
+
currentDir = parentDir;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
282
348
|
/**
|
|
283
349
|
* Escape HTML special characters
|
|
284
350
|
*/
|
|
@@ -199,6 +199,38 @@ else
|
|
|
199
199
|
fi
|
|
200
200
|
fi
|
|
201
201
|
|
|
202
|
+
# ============================================================================
|
|
203
|
+
# EPIC GITHUB ISSUE SYNC (Update Epic issue with fresh task progress)
|
|
204
|
+
# ============================================================================
|
|
205
|
+
|
|
206
|
+
echo "[$(date)] [GitHub] 🔄 Checking for Epic GitHub issue update..." >> "$DEBUG_LOG" 2>/dev/null || true
|
|
207
|
+
|
|
208
|
+
# Find active increment ID
|
|
209
|
+
ACTIVE_INCREMENT=$(ls -t .specweave/increments/ | grep -v '^\.' | while read inc; do
|
|
210
|
+
if [ -f ".specweave/increments/$inc/metadata.json" ]; then
|
|
211
|
+
STATUS=$(grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' ".specweave/increments/$inc/metadata.json" 2>/dev/null | sed 's/.*"\([^"]*\)".*/\1/' || true)
|
|
212
|
+
if [ "$STATUS" = "active" ]; then
|
|
213
|
+
echo "$inc"
|
|
214
|
+
break
|
|
215
|
+
fi
|
|
216
|
+
fi
|
|
217
|
+
done | head -1)
|
|
218
|
+
|
|
219
|
+
if [ -n "$ACTIVE_INCREMENT" ]; then
|
|
220
|
+
echo "[$(date)] [GitHub] 🎯 Active increment: $ACTIVE_INCREMENT" >> "$DEBUG_LOG" 2>/dev/null || true
|
|
221
|
+
|
|
222
|
+
# Run Epic sync script (silently, errors logged to debug log)
|
|
223
|
+
if [ -f "$PROJECT_ROOT/scripts/update-epic-github-issue.sh" ]; then
|
|
224
|
+
echo "[$(date)] [GitHub] 🚀 Updating Epic GitHub issue..." >> "$DEBUG_LOG" 2>/dev/null || true
|
|
225
|
+
"$PROJECT_ROOT/scripts/update-epic-github-issue.sh" "$ACTIVE_INCREMENT" >> "$DEBUG_LOG" 2>&1 || true
|
|
226
|
+
echo "[$(date)] [GitHub] ✅ Epic sync complete (see logs for details)" >> "$DEBUG_LOG" 2>/dev/null || true
|
|
227
|
+
else
|
|
228
|
+
echo "[$(date)] [GitHub] ⚠️ Epic sync script not found, skipping" >> "$DEBUG_LOG" 2>/dev/null || true
|
|
229
|
+
fi
|
|
230
|
+
else
|
|
231
|
+
echo "[$(date)] [GitHub] ℹ️ No active increment found, skipping Epic sync" >> "$DEBUG_LOG" 2>/dev/null || true
|
|
232
|
+
fi
|
|
233
|
+
|
|
202
234
|
# ============================================================================
|
|
203
235
|
# OUTPUT TO CLAUDE
|
|
204
236
|
# ============================================================================
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { readdir, readFile } from "fs/promises";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as yaml from "yaml";
|
|
5
|
+
class EpicContentBuilder {
|
|
6
|
+
constructor(epicFolder, projectRoot) {
|
|
7
|
+
this.epicFolder = epicFolder;
|
|
8
|
+
this.projectRoot = projectRoot;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Build hierarchical GitHub issue body
|
|
12
|
+
*
|
|
13
|
+
* Format:
|
|
14
|
+
* - Epic overview
|
|
15
|
+
* - User Stories section (checkable, with status + increment)
|
|
16
|
+
* - Tasks section (grouped by User Story)
|
|
17
|
+
*/
|
|
18
|
+
async buildIssueBody() {
|
|
19
|
+
const epicData = await this.readEpicMetadata();
|
|
20
|
+
const userStories = await this.readUserStories();
|
|
21
|
+
const overview = this.buildOverviewSection(epicData);
|
|
22
|
+
const userStoriesSection = this.buildUserStoriesSection(userStories);
|
|
23
|
+
const tasksSection = this.buildTasksSection(userStories);
|
|
24
|
+
return `${overview}
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
${userStoriesSection}
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
${tasksSection}
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
\u{1F916} Auto-created by SpecWeave Epic Sync`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Read Epic FEATURE.md frontmatter
|
|
40
|
+
*/
|
|
41
|
+
async readEpicMetadata() {
|
|
42
|
+
const featurePath = path.join(this.epicFolder, "FEATURE.md");
|
|
43
|
+
const content = await readFile(featurePath, "utf-8");
|
|
44
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
45
|
+
if (!match) {
|
|
46
|
+
throw new Error("FEATURE.md missing YAML frontmatter");
|
|
47
|
+
}
|
|
48
|
+
return yaml.parse(match[1]);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Read all user stories from us-*.md files
|
|
52
|
+
*/
|
|
53
|
+
async readUserStories() {
|
|
54
|
+
const files = await readdir(this.epicFolder);
|
|
55
|
+
const usFiles = files.filter((f) => f.startsWith("us-") && f.endsWith(".md"));
|
|
56
|
+
const userStories = [];
|
|
57
|
+
for (const file of usFiles.sort()) {
|
|
58
|
+
const filePath = path.join(this.epicFolder, file);
|
|
59
|
+
const content = await readFile(filePath, "utf-8");
|
|
60
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
61
|
+
if (!match) {
|
|
62
|
+
console.warn(` \u26A0\uFE0F ${file} missing frontmatter, skipping`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const frontmatter = yaml.parse(match[1]);
|
|
66
|
+
const bodyContent = content.slice(match[0].length).trim();
|
|
67
|
+
const incrementMatch = bodyContent.match(/\*\*Increment\*\*:\s*\[([^\]]+)\]/);
|
|
68
|
+
const increment = incrementMatch ? incrementMatch[1] : null;
|
|
69
|
+
const tasks = await this.extractTasksForUserStory(
|
|
70
|
+
frontmatter.id,
|
|
71
|
+
increment,
|
|
72
|
+
bodyContent
|
|
73
|
+
);
|
|
74
|
+
userStories.push({
|
|
75
|
+
id: frontmatter.id,
|
|
76
|
+
title: frontmatter.title,
|
|
77
|
+
status: this.normalizeStatus(frontmatter.status),
|
|
78
|
+
increment,
|
|
79
|
+
tasks
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return userStories;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Extract tasks for a user story from its Implementation section
|
|
86
|
+
*/
|
|
87
|
+
async extractTasksForUserStory(userStoryId, incrementId, content) {
|
|
88
|
+
if (!incrementId) {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
const incrementFolder = path.join(
|
|
92
|
+
this.projectRoot,
|
|
93
|
+
".specweave",
|
|
94
|
+
"increments",
|
|
95
|
+
incrementId
|
|
96
|
+
);
|
|
97
|
+
if (!existsSync(incrementFolder)) {
|
|
98
|
+
console.warn(` \u26A0\uFE0F Increment folder not found: ${incrementId}`);
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
const tasksPath = path.join(incrementFolder, "tasks.md");
|
|
102
|
+
if (!existsSync(tasksPath)) {
|
|
103
|
+
console.warn(` \u26A0\uFE0F tasks.md not found in ${incrementId}`);
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
const tasksContent = await readFile(tasksPath, "utf-8");
|
|
107
|
+
const taskLinkPattern = /- \[([T-\d]+):\s*([^\]]+)\]/g;
|
|
108
|
+
const taskLinks = [];
|
|
109
|
+
let match;
|
|
110
|
+
while ((match = taskLinkPattern.exec(content)) !== null) {
|
|
111
|
+
taskLinks.push({
|
|
112
|
+
id: match[1],
|
|
113
|
+
// e.g., "T-001"
|
|
114
|
+
title: match[2].trim()
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const tasks = [];
|
|
118
|
+
for (const taskLink of taskLinks) {
|
|
119
|
+
const taskPattern = new RegExp(
|
|
120
|
+
`###\\s+${taskLink.id}:\\s*([^\\n]+)[\\s\\S]*?\\*\\*Status\\*\\*:\\s*\\[([x\\s])\\]`,
|
|
121
|
+
"i"
|
|
122
|
+
);
|
|
123
|
+
const taskMatch = tasksContent.match(taskPattern);
|
|
124
|
+
const isCompleted = taskMatch ? taskMatch[2] === "x" : false;
|
|
125
|
+
tasks.push({
|
|
126
|
+
id: taskLink.id,
|
|
127
|
+
title: taskLink.title,
|
|
128
|
+
status: isCompleted,
|
|
129
|
+
userStoryId
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return tasks;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Build overview section
|
|
136
|
+
*/
|
|
137
|
+
buildOverviewSection(epic) {
|
|
138
|
+
return `# [${epic.id}] ${epic.title}
|
|
139
|
+
|
|
140
|
+
**Status**: ${epic.status}
|
|
141
|
+
**Created**: ${epic.created}
|
|
142
|
+
**Last Updated**: ${epic.last_updated}`;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Build User Stories section
|
|
146
|
+
*/
|
|
147
|
+
buildUserStoriesSection(userStories) {
|
|
148
|
+
const total = userStories.length;
|
|
149
|
+
const completed = userStories.filter((us) => us.status === "complete").length;
|
|
150
|
+
const percentage = total > 0 ? Math.round(completed / total * 100) : 0;
|
|
151
|
+
let section = `## User Stories
|
|
152
|
+
|
|
153
|
+
Progress: ${completed}/${total} user stories complete (${percentage}%)
|
|
154
|
+
|
|
155
|
+
`;
|
|
156
|
+
for (const us of userStories) {
|
|
157
|
+
const checkbox = us.status === "complete" ? "[x]" : "[ ]";
|
|
158
|
+
const statusEmoji = this.getStatusEmoji(us.status);
|
|
159
|
+
const incrementLink = us.increment ? `[${us.increment}](../../increments/${us.increment}/)` : "TBD";
|
|
160
|
+
section += `- ${checkbox} **${us.id}: ${us.title}** (${statusEmoji} ${us.status} | Increment: ${incrementLink})
|
|
161
|
+
`;
|
|
162
|
+
}
|
|
163
|
+
return section;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Build Tasks section (grouped by User Story)
|
|
167
|
+
*/
|
|
168
|
+
buildTasksSection(userStories) {
|
|
169
|
+
const totalTasks = userStories.reduce((sum, us) => sum + us.tasks.length, 0);
|
|
170
|
+
const completedTasks = userStories.reduce(
|
|
171
|
+
(sum, us) => sum + us.tasks.filter((t) => t.status).length,
|
|
172
|
+
0
|
|
173
|
+
);
|
|
174
|
+
const percentage = totalTasks > 0 ? Math.round(completedTasks / totalTasks * 100) : 0;
|
|
175
|
+
let section = `## Tasks by User Story
|
|
176
|
+
|
|
177
|
+
Progress: ${completedTasks}/${totalTasks} tasks complete (${percentage}%)
|
|
178
|
+
|
|
179
|
+
`;
|
|
180
|
+
for (const us of userStories) {
|
|
181
|
+
if (us.tasks.length === 0) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const incrementLink = us.increment ? `[${us.increment}](../../increments/${us.increment}/tasks.md)` : "TBD";
|
|
185
|
+
section += `### ${us.id}: ${us.title} (Increment: ${incrementLink})
|
|
186
|
+
|
|
187
|
+
`;
|
|
188
|
+
for (const task of us.tasks) {
|
|
189
|
+
const checkbox = task.status ? "[x]" : "[ ]";
|
|
190
|
+
section += `- ${checkbox} ${task.id}: ${task.title}
|
|
191
|
+
`;
|
|
192
|
+
}
|
|
193
|
+
section += "\n";
|
|
194
|
+
}
|
|
195
|
+
return section;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Normalize status values
|
|
199
|
+
*/
|
|
200
|
+
normalizeStatus(status) {
|
|
201
|
+
const normalized = status.toLowerCase();
|
|
202
|
+
if (normalized === "complete" || normalized === "completed") return "complete";
|
|
203
|
+
if (normalized === "active" || normalized === "in-progress") return "active";
|
|
204
|
+
if (normalized === "planning") return "planning";
|
|
205
|
+
return "not-started";
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Get status emoji
|
|
209
|
+
*/
|
|
210
|
+
getStatusEmoji(status) {
|
|
211
|
+
switch (status) {
|
|
212
|
+
case "complete":
|
|
213
|
+
return "\u2705";
|
|
214
|
+
case "active":
|
|
215
|
+
return "\u{1F6A7}";
|
|
216
|
+
case "planning":
|
|
217
|
+
return "\u{1F4CB}";
|
|
218
|
+
case "not-started":
|
|
219
|
+
return "\u23F3";
|
|
220
|
+
default:
|
|
221
|
+
return "\u2753";
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
export {
|
|
226
|
+
EpicContentBuilder
|
|
227
|
+
};
|