specweave 0.30.14 → 0.30.17
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/CLAUDE.md +43 -0
- package/README.md +32 -0
- package/bin/specweave.js +28 -0
- package/dist/src/cli/commands/commits.d.ts +7 -0
- package/dist/src/cli/commands/commits.d.ts.map +1 -0
- package/dist/src/cli/commands/commits.js +42 -0
- package/dist/src/cli/commands/commits.js.map +1 -0
- package/dist/src/cli/commands/living-docs.d.ts +29 -0
- package/dist/src/cli/commands/living-docs.d.ts.map +1 -0
- package/dist/src/cli/commands/living-docs.js +350 -0
- package/dist/src/cli/commands/living-docs.js.map +1 -0
- package/dist/src/cli/helpers/ado-area-selector.js +1 -1
- package/dist/src/cli/helpers/ado-area-selector.js.map +1 -1
- package/dist/src/cli/workers/living-docs-worker.js +80 -44
- package/dist/src/cli/workers/living-docs-worker.js.map +1 -1
- package/dist/src/core/background/index.d.ts +2 -2
- package/dist/src/core/background/index.d.ts.map +1 -1
- package/dist/src/core/background/index.js +1 -1
- package/dist/src/core/background/index.js.map +1 -1
- package/dist/src/core/living-docs/living-docs-sync.d.ts +60 -24
- package/dist/src/core/living-docs/living-docs-sync.d.ts.map +1 -1
- package/dist/src/core/living-docs/living-docs-sync.js +360 -103
- package/dist/src/core/living-docs/living-docs-sync.js.map +1 -1
- package/dist/src/core/llm/index.d.ts +1 -0
- package/dist/src/core/llm/index.d.ts.map +1 -1
- package/dist/src/core/llm/index.js +2 -0
- package/dist/src/core/llm/index.js.map +1 -1
- package/dist/src/core/llm/providers/anthropic-provider.d.ts.map +1 -1
- package/dist/src/core/llm/providers/anthropic-provider.js +15 -26
- package/dist/src/core/llm/providers/anthropic-provider.js.map +1 -1
- package/dist/src/core/llm/providers/azure-openai-provider.d.ts.map +1 -1
- package/dist/src/core/llm/providers/azure-openai-provider.js +13 -5
- package/dist/src/core/llm/providers/azure-openai-provider.js.map +1 -1
- package/dist/src/core/llm/providers/bedrock-provider.d.ts.map +1 -1
- package/dist/src/core/llm/providers/bedrock-provider.js +12 -8
- package/dist/src/core/llm/providers/bedrock-provider.js.map +1 -1
- package/dist/src/core/llm/providers/claude-code-provider.d.ts.map +1 -1
- package/dist/src/core/llm/providers/claude-code-provider.js +15 -25
- package/dist/src/core/llm/providers/claude-code-provider.js.map +1 -1
- package/dist/src/core/llm/providers/ollama-provider.d.ts.map +1 -1
- package/dist/src/core/llm/providers/ollama-provider.js +12 -9
- package/dist/src/core/llm/providers/ollama-provider.js.map +1 -1
- package/dist/src/core/llm/providers/openai-provider.d.ts.map +1 -1
- package/dist/src/core/llm/providers/openai-provider.js +13 -6
- package/dist/src/core/llm/providers/openai-provider.js.map +1 -1
- package/dist/src/core/llm/providers/vertex-ai-provider.d.ts.map +1 -1
- package/dist/src/core/llm/providers/vertex-ai-provider.js +12 -8
- package/dist/src/core/llm/providers/vertex-ai-provider.js.map +1 -1
- package/dist/src/importers/ado-importer.js +2 -2
- package/dist/src/importers/ado-importer.js.map +1 -1
- package/dist/src/importers/item-converter.d.ts +6 -1
- package/dist/src/importers/item-converter.d.ts.map +1 -1
- package/dist/src/importers/item-converter.js +15 -2
- package/dist/src/importers/item-converter.js.map +1 -1
- package/dist/src/integrations/ado/ado-pat-provider.d.ts +3 -3
- package/dist/src/integrations/ado/ado-pat-provider.js +3 -3
- package/dist/src/living-docs/epic-id-allocator.d.ts +1 -1
- package/dist/src/living-docs/epic-id-allocator.js +1 -1
- package/dist/src/living-docs/fs-id-allocator.d.ts +1 -1
- package/dist/src/living-docs/fs-id-allocator.js +1 -1
- package/dist/src/utils/auth-helpers.d.ts +23 -0
- package/dist/src/utils/auth-helpers.d.ts.map +1 -1
- package/dist/src/utils/auth-helpers.js +51 -0
- package/dist/src/utils/auth-helpers.js.map +1 -1
- package/dist/src/utils/feature-id-collision.d.ts +48 -5
- package/dist/src/utils/feature-id-collision.d.ts.map +1 -1
- package/dist/src/utils/feature-id-collision.js +251 -19
- package/dist/src/utils/feature-id-collision.js.map +1 -1
- package/dist/src/utils/llm-json-extractor.d.ts +105 -0
- package/dist/src/utils/llm-json-extractor.d.ts.map +1 -0
- package/dist/src/utils/llm-json-extractor.js +336 -0
- package/dist/src/utils/llm-json-extractor.js.map +1 -0
- package/dist/src/utils/structure-level-detector.d.ts +105 -0
- package/dist/src/utils/structure-level-detector.d.ts.map +1 -0
- package/dist/src/utils/structure-level-detector.js +388 -0
- package/dist/src/utils/structure-level-detector.js.map +1 -0
- package/dist/src/utils/validators/ado-validator.js +2 -2
- package/dist/src/utils/validators/ado-validator.js.map +1 -1
- package/package.json +1 -2
- package/plugins/specweave/commands/specweave-increment.md +57 -9
- package/plugins/specweave/commands/specweave-living-docs.md +321 -0
- package/plugins/specweave/commands/specweave-sync-specs.md +37 -6
- package/plugins/specweave/hooks/hooks.json +10 -0
- package/plugins/specweave/hooks/spec-project-validator.sh +111 -0
- package/plugins/specweave/hooks/v2/handlers/github-sync-handler.sh +10 -1
- package/plugins/specweave/hooks/v2/handlers/living-docs-handler.sh +10 -1
- package/plugins/specweave/skills/increment-planner/SKILL.md +109 -10
- package/plugins/specweave/skills/increment-planner/templates/spec-multi-project.md +2 -0
- package/plugins/specweave/skills/increment-planner/templates/spec-single-project.md +1 -0
- package/plugins/specweave/skills/multi-project-spec-mapper/SKILL.md +24 -1
- package/plugins/specweave/skills/spec-generator/SKILL.md +18 -0
- package/plugins/specweave/skills/specweave-framework/SKILL.md +25 -0
- package/plugins/specweave-ado/agents/ado-manager/AGENT.md +58 -0
- package/plugins/specweave-ado/commands/pull.md +30 -0
- package/plugins/specweave-ado/commands/push.md +30 -0
- package/plugins/specweave-ado/commands/sync.md +31 -0
- package/plugins/specweave-github/agents/github-manager/AGENT.md +22 -0
- package/plugins/specweave-github/hooks/.specweave/logs/hooks-debug.log +14 -0
- package/plugins/specweave-jira/agents/jira-manager/AGENT.md +30 -0
- package/plugins/specweave-release/hooks/.specweave/logs/dora-tracking.log +21 -0
|
@@ -24,9 +24,11 @@ import { BoardMatcher } from './board-matcher.js';
|
|
|
24
24
|
import { consoleLogger } from '../../utils/logger.js';
|
|
25
25
|
import { autoDetectProjectIdSync } from '../../utils/project-detection.js';
|
|
26
26
|
import { getGitHubAuthFromProject } from '../../utils/auth-helpers.js';
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
27
|
+
// CRITICAL FIX (2025-12-04): Re-enabled collision detection for internal features
|
|
28
|
+
// Bug: When external features (FS-001E) exist, internal features (FS-001) must NOT collide
|
|
29
|
+
// IDs are scoped per project/board - each board has its own FS-XXX sequence
|
|
30
|
+
import { findNextAvailableInternalIdSync } from '../../utils/feature-id-collision.js';
|
|
31
|
+
import { extractIncrementNumber } from '../../utils/feature-id-derivation.js';
|
|
30
32
|
// Import sync profile helpers for provider detection (v0.31.0+)
|
|
31
33
|
import { getProfilesByProvider } from '../types/sync-profile.js';
|
|
32
34
|
// Import extracted helpers
|
|
@@ -98,8 +100,13 @@ export class LivingDocsSync {
|
|
|
98
100
|
}
|
|
99
101
|
// Step 1: Build feature registry (auto-generates IDs for greenfield)
|
|
100
102
|
await this.featureIdManager.buildRegistry();
|
|
101
|
-
// Step 2:
|
|
102
|
-
//
|
|
103
|
+
// Step 2: Resolve project path FIRST (needed for collision detection)
|
|
104
|
+
// CRITICAL FIX (2025-12-04): Moved before feature ID derivation
|
|
105
|
+
// Collision detection is scoped per project/board
|
|
106
|
+
const basePath = path.join(this.projectRoot, '.specweave/docs/internal/specs');
|
|
107
|
+
const resolvedProjectPath = await this.resolveProjectPath(incrementId);
|
|
108
|
+
// Step 3: Get feature ID (derived from increment number WITH collision detection)
|
|
109
|
+
// CRITICAL FIX (2025-12-04): Now checks for FS-XXXE collisions
|
|
103
110
|
let featureId;
|
|
104
111
|
if (options.explicitFeatureId && /^FS-\d{3,}E?$/.test(options.explicitFeatureId)) {
|
|
105
112
|
// Allow explicit override for special cases (e.g., epic linking)
|
|
@@ -107,22 +114,16 @@ export class LivingDocsSync {
|
|
|
107
114
|
this.logger.log(`📎 Using explicit feature ID: ${featureId}`);
|
|
108
115
|
}
|
|
109
116
|
else {
|
|
110
|
-
// Derive from increment number
|
|
111
|
-
featureId = await this.getFeatureIdForIncrement(incrementId);
|
|
117
|
+
// Derive from increment number with collision detection
|
|
118
|
+
featureId = await this.getFeatureIdForIncrement(incrementId, resolvedProjectPath);
|
|
112
119
|
this.logger.log(`🔄 Derived feature ID: ${featureId}`);
|
|
113
120
|
}
|
|
114
121
|
result.featureId = featureId;
|
|
115
122
|
// NOTE (v0.29.0): No longer write feature_id to metadata.json
|
|
116
123
|
// Feature ID is derived from increment number - see ADR-0140
|
|
117
124
|
this.logger.log(`📚 Syncing ${incrementId} → ${featureId}...`);
|
|
118
|
-
// Step
|
|
125
|
+
// Step 4: Parse increment spec
|
|
119
126
|
const parsed = await this.parseIncrementSpec(incrementId);
|
|
120
|
-
// Step 4: Create living docs structure
|
|
121
|
-
// Structure: specs/{project}/FS-XXX/FEATURE.md (+ user stories)
|
|
122
|
-
// CRITICAL FIX (2025-12-02): Use smart project path resolution for brownfield setups
|
|
123
|
-
// This handles hierarchical paths like "acme/digital-operations-services"
|
|
124
|
-
const basePath = path.join(this.projectRoot, '.specweave/docs/internal/specs');
|
|
125
|
-
const resolvedProjectPath = await this.resolveProjectPath(incrementId);
|
|
126
127
|
// Create {project}/FS-XXX/FEATURE.md
|
|
127
128
|
const projectPath = path.join(basePath, resolvedProjectPath, featureId);
|
|
128
129
|
this.logger.log(` 📁 Feature folder: ${resolvedProjectPath}/${featureId}/`);
|
|
@@ -201,163 +202,245 @@ export class LivingDocsSync {
|
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
/**
|
|
204
|
-
* Get feature ID for increment
|
|
205
|
+
* Get feature ID for increment with collision detection
|
|
205
206
|
*
|
|
206
207
|
* SIMPLIFIED (v0.29.0): Feature ID is derived from increment number
|
|
207
208
|
* No longer reads from metadata.json - derivation is the single source of truth
|
|
208
209
|
*
|
|
209
|
-
* CRITICAL FIX (2025-12-
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
210
|
+
* CRITICAL FIX (2025-12-04): Re-enabled collision detection!
|
|
211
|
+
* The previous assumption that "internal features are deterministic" was WRONG
|
|
212
|
+
* when external imports exist. If FS-001E exists, internal increment 0001
|
|
213
|
+
* must use FS-002 to avoid collision.
|
|
213
214
|
*
|
|
214
|
-
* Collision detection is
|
|
215
|
-
*
|
|
215
|
+
* Collision detection is SCOPED per project/board:
|
|
216
|
+
* - Each project has its own FS-XXX sequence
|
|
217
|
+
* - Scans both FS-XXX and FS-XXXE folders
|
|
218
|
+
* - Also scans _orphans folder for US-XXX files
|
|
216
219
|
*
|
|
220
|
+
* @param incrementId - Increment ID (e.g., "0081-ado-repo-cloning")
|
|
221
|
+
* @param resolvedProjectPath - Resolved project path (e.g., "acme/backend" or "my-project")
|
|
222
|
+
* @returns Feature ID with collision avoidance (e.g., "FS-081" or "FS-082" if 081 exists)
|
|
217
223
|
* @see deriveFeatureId() in src/utils/feature-id-derivation.ts
|
|
218
|
-
* @see ADR-0140 for rationale
|
|
219
224
|
*/
|
|
220
|
-
async getFeatureIdForIncrement(incrementId) {
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
225
|
+
async getFeatureIdForIncrement(incrementId, resolvedProjectPath) {
|
|
226
|
+
// Extract increment number (e.g., "0081-name" → 81)
|
|
227
|
+
const incrementNumber = extractIncrementNumber(incrementId);
|
|
228
|
+
// Build path to project's specs folder for collision detection
|
|
229
|
+
const specsPath = path.join(this.projectRoot, '.specweave/docs/internal/specs');
|
|
230
|
+
// CRITICAL: Use collision detection to avoid FS-XXX vs FS-XXXE conflicts
|
|
231
|
+
// This is scoped to the specific project/board folder
|
|
232
|
+
const safeNumber = findNextAvailableInternalIdSync(incrementNumber, specsPath, resolvedProjectPath, { logger: this.logger });
|
|
233
|
+
// Generate feature ID from safe number
|
|
234
|
+
const featureId = `FS-${String(safeNumber).padStart(3, '0')}`;
|
|
235
|
+
// Log if collision was avoided
|
|
236
|
+
if (safeNumber !== incrementNumber) {
|
|
237
|
+
this.logger.warn(` ⚠️ Feature ID collision avoided: FS-${String(incrementNumber).padStart(3, '0')} exists, ` +
|
|
238
|
+
`using ${featureId} instead`);
|
|
239
|
+
}
|
|
240
|
+
return featureId;
|
|
224
241
|
}
|
|
225
242
|
/**
|
|
226
|
-
* Extract project
|
|
243
|
+
* Extract project and board from increment spec.md YAML frontmatter
|
|
244
|
+
*
|
|
245
|
+
* Priority:
|
|
246
|
+
* 1. YAML frontmatter `project:` field (v0.31.0+ - preferred)
|
|
247
|
+
* 2. Legacy **Project**: field in body (backward compatibility)
|
|
227
248
|
*
|
|
228
|
-
*
|
|
249
|
+
* For 2-level structures, also extracts `board:` field.
|
|
229
250
|
*
|
|
230
|
-
* SECURITY: Validates both incrementId and extracted
|
|
251
|
+
* SECURITY: Validates both incrementId and extracted names to prevent path traversal
|
|
231
252
|
*
|
|
232
253
|
* @param incrementId - Increment ID (e.g., "0002-test-anton-monitor")
|
|
233
|
-
* @returns
|
|
254
|
+
* @returns Object with project and board (null if not specified or invalid)
|
|
234
255
|
*/
|
|
235
|
-
async
|
|
256
|
+
async extractProjectBoardFromSpec(incrementId) {
|
|
236
257
|
// SECURITY FIX (2025-12-02): Validate incrementId format FIRST
|
|
237
|
-
// Prevents path traversal via malicious increment IDs like "../../../etc"
|
|
238
258
|
if (!incrementId || !/^\d{4}-[a-z0-9-]+$/i.test(incrementId)) {
|
|
239
259
|
this.logger.warn(` ⚠️ Invalid increment ID format: ${incrementId}`);
|
|
240
|
-
return null;
|
|
260
|
+
return { project: null, board: null };
|
|
241
261
|
}
|
|
242
262
|
const specPath = path.join(this.projectRoot, '.specweave/increments', incrementId, 'spec.md');
|
|
243
263
|
if (!existsSync(specPath)) {
|
|
244
|
-
return null;
|
|
264
|
+
return { project: null, board: null };
|
|
245
265
|
}
|
|
246
266
|
try {
|
|
247
267
|
const content = await fs.readFile(specPath, 'utf-8');
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
// SECURITY: Minimum length check to prevent empty names after stripping
|
|
259
|
-
if (!projectName || projectName.length < 2) {
|
|
260
|
-
this.logger.warn(` ⚠️ Project name too short: ${projectName}`);
|
|
261
|
-
return null;
|
|
268
|
+
let project = null;
|
|
269
|
+
let board = null;
|
|
270
|
+
// 1. Try YAML frontmatter first (preferred - v0.31.0+)
|
|
271
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
272
|
+
if (frontmatterMatch) {
|
|
273
|
+
const frontmatter = frontmatterMatch[1];
|
|
274
|
+
// Extract project from YAML
|
|
275
|
+
const yamlProjectMatch = frontmatter.match(/^project:\s*["']?([^"'\n]+)["']?$/m);
|
|
276
|
+
if (yamlProjectMatch && yamlProjectMatch[1]) {
|
|
277
|
+
project = this.validateAndNormalizeName(yamlProjectMatch[1].trim(), 'project');
|
|
262
278
|
}
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
projectName.includes('\\') ||
|
|
268
|
-
projectName.includes('\0')) {
|
|
269
|
-
this.logger.warn(` ⚠️ Invalid project name (potential path traversal): ${projectName}`);
|
|
270
|
-
return null;
|
|
279
|
+
// Extract board from YAML
|
|
280
|
+
const yamlBoardMatch = frontmatter.match(/^board:\s*["']?([^"'\n]+)["']?$/m);
|
|
281
|
+
if (yamlBoardMatch && yamlBoardMatch[1]) {
|
|
282
|
+
board = this.validateAndNormalizeName(yamlBoardMatch[1].trim(), 'board');
|
|
271
283
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
284
|
+
}
|
|
285
|
+
// 2. Fallback to legacy **Project**: field in body
|
|
286
|
+
if (!project) {
|
|
287
|
+
const bodyProjectMatch = content.match(/\*\*Project\*\*:\s*(.+?)(?:\n|$)/i);
|
|
288
|
+
if (bodyProjectMatch && bodyProjectMatch[1]) {
|
|
289
|
+
const rawName = bodyProjectMatch[1]
|
|
290
|
+
.trim()
|
|
291
|
+
.replace(/\*\*/g, '')
|
|
292
|
+
.replace(/__/g, '')
|
|
293
|
+
.replace(/`/g, '');
|
|
294
|
+
project = this.validateAndNormalizeName(rawName, 'project');
|
|
295
|
+
if (project) {
|
|
296
|
+
this.logger.log(` ℹ️ Using legacy **Project**: field - consider migrating to YAML frontmatter`);
|
|
297
|
+
}
|
|
276
298
|
}
|
|
277
|
-
return projectName;
|
|
278
299
|
}
|
|
300
|
+
return { project, board };
|
|
279
301
|
}
|
|
280
302
|
catch {
|
|
281
|
-
|
|
303
|
+
return { project: null, board: null };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Validate and normalize a project/board name
|
|
308
|
+
*
|
|
309
|
+
* SECURITY: Prevents path traversal and validates format
|
|
310
|
+
*/
|
|
311
|
+
validateAndNormalizeName(rawName, fieldType) {
|
|
312
|
+
if (!rawName)
|
|
313
|
+
return null;
|
|
314
|
+
const normalized = rawName.toLowerCase().replace(/\s+/g, '-');
|
|
315
|
+
// Minimum length check
|
|
316
|
+
if (normalized.length < 2) {
|
|
317
|
+
this.logger.warn(` ⚠️ ${fieldType} name too short: ${normalized}`);
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
// Block path traversal attempts
|
|
321
|
+
if (normalized.includes('..') ||
|
|
322
|
+
normalized.includes('/') ||
|
|
323
|
+
normalized.includes('\\') ||
|
|
324
|
+
normalized.includes('\0')) {
|
|
325
|
+
this.logger.warn(` ⚠️ Invalid ${fieldType} name (potential path traversal): ${normalized}`);
|
|
326
|
+
return null;
|
|
282
327
|
}
|
|
283
|
-
|
|
328
|
+
// Validate characters
|
|
329
|
+
if (!/^[a-z0-9_-]+$/.test(normalized)) {
|
|
330
|
+
this.logger.warn(` ⚠️ Invalid ${fieldType} name (invalid characters): ${normalized}`);
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
return normalized;
|
|
284
334
|
}
|
|
285
335
|
/**
|
|
286
|
-
*
|
|
336
|
+
* Extract project name from increment spec.md (legacy method - delegates to new method)
|
|
337
|
+
*/
|
|
338
|
+
async extractProjectFromSpec(incrementId) {
|
|
339
|
+
const { project } = await this.extractProjectBoardFromSpec(incrementId);
|
|
340
|
+
return project;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Resolve the project path for an increment (v0.31.0+ - SPEC.MD FRONTMATTER REQUIRED)
|
|
287
344
|
*
|
|
288
|
-
* For
|
|
289
|
-
*
|
|
345
|
+
* For new increments (v0.31.0+), spec.md MUST have `project:` field in YAML frontmatter.
|
|
346
|
+
* For 2-level structures, spec.md MUST have BOTH `project:` and `board:` fields.
|
|
290
347
|
*
|
|
291
348
|
* Priority:
|
|
292
|
-
* 1.
|
|
293
|
-
* 2.
|
|
294
|
-
*
|
|
295
|
-
* - Medium/Low confidence: ask user
|
|
296
|
-
* 3. Existing folder match (hierarchical search)
|
|
297
|
-
* 4. Default project ID (single-project fallback)
|
|
349
|
+
* 1. YAML frontmatter `project:` and `board:` fields (REQUIRED for v0.31.0+)
|
|
350
|
+
* 2. Legacy **Project**: field in body (backward compatibility only)
|
|
351
|
+
* 3. Fallback: intelligent board matching (deprecated - will warn)
|
|
298
352
|
*
|
|
299
353
|
* @param incrementId - Increment ID (e.g., "0002-test-anton-monitor")
|
|
300
|
-
* @returns Project path (
|
|
354
|
+
* @returns Project path (e.g., "my-project" or "acme-corp/digital-ops")
|
|
355
|
+
* @throws Error if required fields are missing in spec.md
|
|
301
356
|
*/
|
|
302
357
|
async resolveProjectPath(incrementId) {
|
|
303
|
-
//
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
358
|
+
// Import structure level detector
|
|
359
|
+
const { detectStructureLevel } = await import('../../utils/structure-level-detector.js');
|
|
360
|
+
const structureConfig = detectStructureLevel(this.projectRoot);
|
|
361
|
+
// 1. Extract project and board from spec.md
|
|
362
|
+
const { project, board } = await this.extractProjectBoardFromSpec(incrementId);
|
|
363
|
+
// 2. For 2-level structures, REQUIRE both project AND board
|
|
364
|
+
if (structureConfig.level === 2) {
|
|
365
|
+
if (!project) {
|
|
366
|
+
this.logger.error(`❌ Missing 'project:' field in spec.md YAML frontmatter`);
|
|
367
|
+
this.logger.error(` This is a 2-level structure - spec.md MUST have both 'project:' and 'board:' fields.`);
|
|
368
|
+
this.logger.error(` Detection reason: ${structureConfig.detectionReason}`);
|
|
369
|
+
this.logger.error(` Available projects: ${structureConfig.projects.map(p => p.id).join(', ')}`);
|
|
370
|
+
throw new Error(`spec.md missing required 'project:' field. ` +
|
|
371
|
+
`This is a 2-level structure (${structureConfig.detectionReason}). ` +
|
|
372
|
+
`Add 'project: <project_name>' to YAML frontmatter.`);
|
|
373
|
+
}
|
|
374
|
+
if (!board) {
|
|
375
|
+
const boardOptions = structureConfig.boardsByProject?.[project]
|
|
376
|
+
? structureConfig.boardsByProject[project].map(b => b.id).join(', ')
|
|
377
|
+
: 'N/A';
|
|
378
|
+
this.logger.error(`❌ Missing 'board:' field in spec.md YAML frontmatter`);
|
|
379
|
+
this.logger.error(` This is a 2-level structure - spec.md MUST have both 'project:' and 'board:' fields.`);
|
|
380
|
+
this.logger.error(` Detection reason: ${structureConfig.detectionReason}`);
|
|
381
|
+
this.logger.error(` Project: ${project}`);
|
|
382
|
+
this.logger.error(` Available boards: ${boardOptions}`);
|
|
383
|
+
throw new Error(`spec.md missing required 'board:' field. ` +
|
|
384
|
+
`This is a 2-level structure (${structureConfig.detectionReason}). ` +
|
|
385
|
+
`Add 'board: <board_name>' to YAML frontmatter. ` +
|
|
386
|
+
`Available boards for ${project}: ${boardOptions}`);
|
|
387
|
+
}
|
|
388
|
+
// Construct 2-level path: {project}/{board}
|
|
389
|
+
const fullPath = `${project}/${board}`;
|
|
390
|
+
this.logger.log(` 📁 2-level path from spec.md: ${fullPath}`);
|
|
391
|
+
return fullPath;
|
|
392
|
+
}
|
|
393
|
+
// 3. For 1-level structures, project is REQUIRED (but not board)
|
|
394
|
+
if (project) {
|
|
395
|
+
this.logger.log(` 📎 Using project from spec.md: ${project}`);
|
|
396
|
+
// Try to find existing hierarchical path (for backward compatibility)
|
|
310
397
|
const specsBase = path.join(this.projectRoot, '.specweave/docs/internal/specs');
|
|
311
398
|
if (existsSync(specsBase)) {
|
|
312
|
-
const foundPath = await this.findBestProjectMatch(specsBase,
|
|
399
|
+
const foundPath = await this.findBestProjectMatch(specsBase, project);
|
|
313
400
|
if (foundPath) {
|
|
314
401
|
this.logger.log(` 🔍 Found existing project path: ${foundPath}`);
|
|
315
402
|
return foundPath;
|
|
316
403
|
}
|
|
317
404
|
}
|
|
318
|
-
return
|
|
405
|
+
return project;
|
|
319
406
|
}
|
|
320
|
-
//
|
|
407
|
+
// 4. No project in spec.md - WARN and fallback to auto-detection (deprecated behavior)
|
|
408
|
+
this.logger.warn(` ⚠️ No 'project:' field in spec.md YAML frontmatter`);
|
|
409
|
+
this.logger.warn(` 💡 Add 'project: <project_name>' to spec.md frontmatter for explicit sync target`);
|
|
410
|
+
this.logger.warn(` ⚠️ Using auto-detection (deprecated - will be required in future versions)`);
|
|
411
|
+
// Fallback: intelligent board matching (deprecated)
|
|
321
412
|
const availableBoards = await this.boardMatcher.getAvailableBoards();
|
|
322
413
|
if (availableBoards.length === 0) {
|
|
323
|
-
// No boards configured - use default project detection
|
|
324
414
|
return this.projectId;
|
|
325
415
|
}
|
|
326
416
|
if (availableBoards.length === 1) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const fullPath = `${board.adoProject}/${board.id}`;
|
|
332
|
-
this.logger.log(` 📁 Single ADO board: ${board.name} → ${fullPath}`);
|
|
417
|
+
const singleBoard = availableBoards[0];
|
|
418
|
+
if (singleBoard.adoProject) {
|
|
419
|
+
const fullPath = `${singleBoard.adoProject}/${singleBoard.id}`;
|
|
420
|
+
this.logger.log(` 📁 Single ADO board (auto-detected): ${singleBoard.name} → ${fullPath}`);
|
|
333
421
|
return fullPath;
|
|
334
422
|
}
|
|
335
|
-
this.logger.log(` 📁 Single board
|
|
336
|
-
return
|
|
423
|
+
this.logger.log(` 📁 Single board (auto-detected): ${singleBoard.name}`);
|
|
424
|
+
return singleBoard.id;
|
|
337
425
|
}
|
|
338
426
|
// Multiple boards - run intelligent matching
|
|
339
427
|
const specContent = await this.getIncrementSpecContent(incrementId);
|
|
340
428
|
const matchDecision = await this.boardMatcher.matchIncrement(incrementId, specContent);
|
|
341
|
-
// Check if this is a utility/cross-cutting increment
|
|
342
429
|
if (this.boardMatcher.isUtilityIncrement(specContent)) {
|
|
343
430
|
this.logger.log(` 🔧 Utility increment detected - asking user for board selection`);
|
|
344
431
|
return await this.askUserForBoardSelection(incrementId, matchDecision);
|
|
345
432
|
}
|
|
346
|
-
// Handle match decision
|
|
347
433
|
if (!matchDecision.needsUserInput && matchDecision.bestMatch) {
|
|
348
|
-
// High confidence match - auto-assign
|
|
349
434
|
const match = matchDecision.bestMatch;
|
|
350
435
|
this.logger.log(` ✅ Auto-matched to board: ${match.boardName} (${match.confidence}% confidence)`);
|
|
351
436
|
if (match.matchedTerms.length > 0) {
|
|
352
437
|
this.logger.log(` Matched terms: ${match.matchedTerms.slice(0, 5).join(', ')}`);
|
|
353
438
|
}
|
|
354
|
-
// CRITICAL FIX (v0.30.13): For ADO with 2-level structure, construct {project}/{board}
|
|
355
439
|
if (match.adoProject) {
|
|
356
440
|
return `${match.adoProject}/${match.boardId}`;
|
|
357
441
|
}
|
|
358
442
|
return match.boardId;
|
|
359
443
|
}
|
|
360
|
-
// Need user input - show match results and ask
|
|
361
444
|
return await this.askUserForBoardSelection(incrementId, matchDecision);
|
|
362
445
|
}
|
|
363
446
|
/**
|
|
@@ -1100,18 +1183,192 @@ export class LivingDocsSync {
|
|
|
1100
1183
|
}
|
|
1101
1184
|
}
|
|
1102
1185
|
/**
|
|
1103
|
-
* Sync to JIRA
|
|
1186
|
+
* Sync to JIRA Epics
|
|
1187
|
+
*
|
|
1188
|
+
* Uses JiraSpecSync.syncSpecToJira() which is idempotent:
|
|
1189
|
+
* - Uses existing epic if it exists
|
|
1190
|
+
* - Updates existing stories
|
|
1191
|
+
* - Only creates new stories if they don't exist
|
|
1192
|
+
*
|
|
1193
|
+
* Configuration priority:
|
|
1194
|
+
* 1. config.sync.jira (most common)
|
|
1195
|
+
* 2. config.sync.profiles[*] with provider='jira'
|
|
1196
|
+
* 3. Environment variables (JIRA_DOMAIN, JIRA_EMAIL, JIRA_API_TOKEN)
|
|
1104
1197
|
*/
|
|
1105
1198
|
async syncToJira(featureId, projectPath) {
|
|
1106
|
-
|
|
1107
|
-
|
|
1199
|
+
try {
|
|
1200
|
+
this.logger.log(` 🔄 Syncing to JIRA...`);
|
|
1201
|
+
// Dynamic import to avoid circular dependencies
|
|
1202
|
+
const { JiraSpecSync } = await import('../../../plugins/specweave-jira/lib/jira-spec-sync.js');
|
|
1203
|
+
// Load JIRA config from config.json FIRST, then environment
|
|
1204
|
+
const configPath = path.join(this.projectRoot, '.specweave/config.json');
|
|
1205
|
+
let domain = process.env.JIRA_DOMAIN || '';
|
|
1206
|
+
let email = process.env.JIRA_EMAIL || '';
|
|
1207
|
+
let apiToken = process.env.JIRA_API_TOKEN || '';
|
|
1208
|
+
let projectKey = '';
|
|
1209
|
+
if (existsSync(configPath)) {
|
|
1210
|
+
try {
|
|
1211
|
+
const config = await readJson(configPath);
|
|
1212
|
+
// Method 1: Read from config.sync.jira (most common)
|
|
1213
|
+
if (config.sync?.jira?.domain) {
|
|
1214
|
+
domain = config.sync.jira.domain;
|
|
1215
|
+
projectKey = config.sync.jira.projectKey || '';
|
|
1216
|
+
this.logger.log(` 📝 Using JIRA config: ${domain}`);
|
|
1217
|
+
}
|
|
1218
|
+
// Method 2: Check profiles
|
|
1219
|
+
else {
|
|
1220
|
+
const jiraProfiles = getProfilesByProvider(config.sync, 'jira');
|
|
1221
|
+
if (jiraProfiles.length > 0) {
|
|
1222
|
+
const [profileName, profile] = jiraProfiles[0];
|
|
1223
|
+
const cfg = profile.config;
|
|
1224
|
+
domain = cfg?.domain || domain;
|
|
1225
|
+
projectKey = cfg?.projectKey || projectKey;
|
|
1226
|
+
this.logger.log(` 📝 Using JIRA profile: ${profileName}`);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
catch (error) {
|
|
1231
|
+
this.logger.warn(` ⚠️ Failed to read config.json for JIRA, using environment variables`);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
// Load secrets from .env if not already set
|
|
1235
|
+
if (!apiToken) {
|
|
1236
|
+
const envPath = path.join(this.projectRoot, '.env');
|
|
1237
|
+
if (existsSync(envPath)) {
|
|
1238
|
+
try {
|
|
1239
|
+
const envContent = await fs.readFile(envPath, 'utf-8');
|
|
1240
|
+
for (const line of envContent.split('\n')) {
|
|
1241
|
+
if (line.startsWith('JIRA_API_TOKEN=')) {
|
|
1242
|
+
apiToken = line.split('=')[1]?.trim().replace(/^["']|["']$/g, '') || '';
|
|
1243
|
+
}
|
|
1244
|
+
if (!email && line.startsWith('JIRA_EMAIL=')) {
|
|
1245
|
+
email = line.split('=')[1]?.trim().replace(/^["']|["']$/g, '') || '';
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
catch {
|
|
1250
|
+
// Ignore .env read errors
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
if (!domain || !email || !apiToken) {
|
|
1255
|
+
this.logger.warn(` ⚠️ JIRA credentials not configured`);
|
|
1256
|
+
this.logger.warn(` 💡 Set JIRA_DOMAIN, JIRA_EMAIL, JIRA_API_TOKEN in .env`);
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
// Initialize JIRA sync
|
|
1260
|
+
const jiraSync = new JiraSpecSync({ domain, email, apiToken, projectKey }, this.projectRoot);
|
|
1261
|
+
// Sync feature to JIRA
|
|
1262
|
+
const result = await jiraSync.syncSpecToJira(featureId);
|
|
1263
|
+
if (result.success) {
|
|
1264
|
+
this.logger.log(` ✅ Synced to JIRA: ${result.externalId || 'updated'}`);
|
|
1265
|
+
}
|
|
1266
|
+
else {
|
|
1267
|
+
this.logger.warn(` ⚠️ JIRA sync had issues: ${result.error || 'unknown'}`);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
catch (error) {
|
|
1271
|
+
if (error instanceof Error && error.message.includes('Cannot find module')) {
|
|
1272
|
+
this.logger.warn(` ⚠️ JIRA plugin not installed - skipping JIRA sync`);
|
|
1273
|
+
}
|
|
1274
|
+
else {
|
|
1275
|
+
throw error;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1108
1278
|
}
|
|
1109
1279
|
/**
|
|
1110
|
-
* Sync to Azure DevOps
|
|
1280
|
+
* Sync to Azure DevOps Features
|
|
1281
|
+
*
|
|
1282
|
+
* Uses AdoSpecSync.syncSpecToAdo() which is idempotent:
|
|
1283
|
+
* - Uses existing feature if it exists
|
|
1284
|
+
* - Updates existing user stories
|
|
1285
|
+
* - Only creates new user stories if they don't exist
|
|
1286
|
+
*
|
|
1287
|
+
* Configuration priority:
|
|
1288
|
+
* 1. config.sync.ado (most common)
|
|
1289
|
+
* 2. config.sync.profiles[*] with provider='ado'
|
|
1290
|
+
* 3. Environment variables (AZURE_DEVOPS_ORG, AZURE_DEVOPS_PROJECT, AZURE_DEVOPS_PAT)
|
|
1111
1291
|
*/
|
|
1112
1292
|
async syncToADO(featureId, projectPath) {
|
|
1113
|
-
|
|
1114
|
-
|
|
1293
|
+
try {
|
|
1294
|
+
this.logger.log(` 🔄 Syncing to Azure DevOps...`);
|
|
1295
|
+
// Dynamic import to avoid circular dependencies
|
|
1296
|
+
const { AdoSpecSync } = await import('../../../plugins/specweave-ado/lib/ado-spec-sync.js');
|
|
1297
|
+
// Load ADO config from config.json FIRST, then environment
|
|
1298
|
+
const configPath = path.join(this.projectRoot, '.specweave/config.json');
|
|
1299
|
+
let organization = process.env.AZURE_DEVOPS_ORG || '';
|
|
1300
|
+
let project = process.env.AZURE_DEVOPS_PROJECT || '';
|
|
1301
|
+
let personalAccessToken = '';
|
|
1302
|
+
if (existsSync(configPath)) {
|
|
1303
|
+
try {
|
|
1304
|
+
const config = await readJson(configPath);
|
|
1305
|
+
// Method 1: Read from config.sync.ado (most common)
|
|
1306
|
+
if (config.sync?.ado?.organization) {
|
|
1307
|
+
organization = config.sync.ado.organization;
|
|
1308
|
+
project = config.sync.ado.project || project;
|
|
1309
|
+
this.logger.log(` 📝 Using ADO config: ${organization}/${project}`);
|
|
1310
|
+
}
|
|
1311
|
+
// Method 2: Check profiles
|
|
1312
|
+
else {
|
|
1313
|
+
const adoProfiles = getProfilesByProvider(config.sync, 'ado');
|
|
1314
|
+
if (adoProfiles.length > 0) {
|
|
1315
|
+
const [profileName, profile] = adoProfiles[0];
|
|
1316
|
+
const cfg = profile.config;
|
|
1317
|
+
organization = cfg?.organization || organization;
|
|
1318
|
+
project = cfg?.project || project;
|
|
1319
|
+
this.logger.log(` 📝 Using ADO profile: ${profileName}`);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
catch (error) {
|
|
1324
|
+
this.logger.warn(` ⚠️ Failed to read config.json for ADO, using environment variables`);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
// Load PAT from .env if not already set
|
|
1328
|
+
const envPath = path.join(this.projectRoot, '.env');
|
|
1329
|
+
if (existsSync(envPath)) {
|
|
1330
|
+
try {
|
|
1331
|
+
const envContent = await fs.readFile(envPath, 'utf-8');
|
|
1332
|
+
for (const line of envContent.split('\n')) {
|
|
1333
|
+
if (line.startsWith('AZURE_DEVOPS_PAT=')) {
|
|
1334
|
+
personalAccessToken = line.split('=')[1]?.trim().replace(/^["']|["']$/g, '') || '';
|
|
1335
|
+
}
|
|
1336
|
+
if (!organization && line.startsWith('AZURE_DEVOPS_ORG=')) {
|
|
1337
|
+
organization = line.split('=')[1]?.trim().replace(/^["']|["']$/g, '') || '';
|
|
1338
|
+
}
|
|
1339
|
+
if (!project && line.startsWith('AZURE_DEVOPS_PROJECT=')) {
|
|
1340
|
+
project = line.split('=')[1]?.trim().replace(/^["']|["']$/g, '') || '';
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
catch {
|
|
1345
|
+
// Ignore .env read errors
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
if (!organization || !project || !personalAccessToken) {
|
|
1349
|
+
this.logger.warn(` ⚠️ ADO credentials not configured`);
|
|
1350
|
+
this.logger.warn(` 💡 Set AZURE_DEVOPS_ORG, AZURE_DEVOPS_PROJECT, AZURE_DEVOPS_PAT in .env`);
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
// Initialize ADO sync
|
|
1354
|
+
const adoSync = new AdoSpecSync({ organization, project, personalAccessToken }, this.projectRoot);
|
|
1355
|
+
// Sync feature to ADO
|
|
1356
|
+
const result = await adoSync.syncSpecToAdo(featureId);
|
|
1357
|
+
if (result.success) {
|
|
1358
|
+
this.logger.log(` ✅ Synced to ADO: ${result.externalId || 'updated'}`);
|
|
1359
|
+
}
|
|
1360
|
+
else {
|
|
1361
|
+
this.logger.warn(` ⚠️ ADO sync had issues: ${result.error || 'unknown'}`);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
catch (error) {
|
|
1365
|
+
if (error instanceof Error && error.message.includes('Cannot find module')) {
|
|
1366
|
+
this.logger.warn(` ⚠️ ADO plugin not installed - skipping ADO sync`);
|
|
1367
|
+
}
|
|
1368
|
+
else {
|
|
1369
|
+
throw error;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1115
1372
|
}
|
|
1116
1373
|
/**
|
|
1117
1374
|
* Validate feature folder consistency
|