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.
Files changed (100) hide show
  1. package/CLAUDE.md +43 -0
  2. package/README.md +32 -0
  3. package/bin/specweave.js +28 -0
  4. package/dist/src/cli/commands/commits.d.ts +7 -0
  5. package/dist/src/cli/commands/commits.d.ts.map +1 -0
  6. package/dist/src/cli/commands/commits.js +42 -0
  7. package/dist/src/cli/commands/commits.js.map +1 -0
  8. package/dist/src/cli/commands/living-docs.d.ts +29 -0
  9. package/dist/src/cli/commands/living-docs.d.ts.map +1 -0
  10. package/dist/src/cli/commands/living-docs.js +350 -0
  11. package/dist/src/cli/commands/living-docs.js.map +1 -0
  12. package/dist/src/cli/helpers/ado-area-selector.js +1 -1
  13. package/dist/src/cli/helpers/ado-area-selector.js.map +1 -1
  14. package/dist/src/cli/workers/living-docs-worker.js +80 -44
  15. package/dist/src/cli/workers/living-docs-worker.js.map +1 -1
  16. package/dist/src/core/background/index.d.ts +2 -2
  17. package/dist/src/core/background/index.d.ts.map +1 -1
  18. package/dist/src/core/background/index.js +1 -1
  19. package/dist/src/core/background/index.js.map +1 -1
  20. package/dist/src/core/living-docs/living-docs-sync.d.ts +60 -24
  21. package/dist/src/core/living-docs/living-docs-sync.d.ts.map +1 -1
  22. package/dist/src/core/living-docs/living-docs-sync.js +360 -103
  23. package/dist/src/core/living-docs/living-docs-sync.js.map +1 -1
  24. package/dist/src/core/llm/index.d.ts +1 -0
  25. package/dist/src/core/llm/index.d.ts.map +1 -1
  26. package/dist/src/core/llm/index.js +2 -0
  27. package/dist/src/core/llm/index.js.map +1 -1
  28. package/dist/src/core/llm/providers/anthropic-provider.d.ts.map +1 -1
  29. package/dist/src/core/llm/providers/anthropic-provider.js +15 -26
  30. package/dist/src/core/llm/providers/anthropic-provider.js.map +1 -1
  31. package/dist/src/core/llm/providers/azure-openai-provider.d.ts.map +1 -1
  32. package/dist/src/core/llm/providers/azure-openai-provider.js +13 -5
  33. package/dist/src/core/llm/providers/azure-openai-provider.js.map +1 -1
  34. package/dist/src/core/llm/providers/bedrock-provider.d.ts.map +1 -1
  35. package/dist/src/core/llm/providers/bedrock-provider.js +12 -8
  36. package/dist/src/core/llm/providers/bedrock-provider.js.map +1 -1
  37. package/dist/src/core/llm/providers/claude-code-provider.d.ts.map +1 -1
  38. package/dist/src/core/llm/providers/claude-code-provider.js +15 -25
  39. package/dist/src/core/llm/providers/claude-code-provider.js.map +1 -1
  40. package/dist/src/core/llm/providers/ollama-provider.d.ts.map +1 -1
  41. package/dist/src/core/llm/providers/ollama-provider.js +12 -9
  42. package/dist/src/core/llm/providers/ollama-provider.js.map +1 -1
  43. package/dist/src/core/llm/providers/openai-provider.d.ts.map +1 -1
  44. package/dist/src/core/llm/providers/openai-provider.js +13 -6
  45. package/dist/src/core/llm/providers/openai-provider.js.map +1 -1
  46. package/dist/src/core/llm/providers/vertex-ai-provider.d.ts.map +1 -1
  47. package/dist/src/core/llm/providers/vertex-ai-provider.js +12 -8
  48. package/dist/src/core/llm/providers/vertex-ai-provider.js.map +1 -1
  49. package/dist/src/importers/ado-importer.js +2 -2
  50. package/dist/src/importers/ado-importer.js.map +1 -1
  51. package/dist/src/importers/item-converter.d.ts +6 -1
  52. package/dist/src/importers/item-converter.d.ts.map +1 -1
  53. package/dist/src/importers/item-converter.js +15 -2
  54. package/dist/src/importers/item-converter.js.map +1 -1
  55. package/dist/src/integrations/ado/ado-pat-provider.d.ts +3 -3
  56. package/dist/src/integrations/ado/ado-pat-provider.js +3 -3
  57. package/dist/src/living-docs/epic-id-allocator.d.ts +1 -1
  58. package/dist/src/living-docs/epic-id-allocator.js +1 -1
  59. package/dist/src/living-docs/fs-id-allocator.d.ts +1 -1
  60. package/dist/src/living-docs/fs-id-allocator.js +1 -1
  61. package/dist/src/utils/auth-helpers.d.ts +23 -0
  62. package/dist/src/utils/auth-helpers.d.ts.map +1 -1
  63. package/dist/src/utils/auth-helpers.js +51 -0
  64. package/dist/src/utils/auth-helpers.js.map +1 -1
  65. package/dist/src/utils/feature-id-collision.d.ts +48 -5
  66. package/dist/src/utils/feature-id-collision.d.ts.map +1 -1
  67. package/dist/src/utils/feature-id-collision.js +251 -19
  68. package/dist/src/utils/feature-id-collision.js.map +1 -1
  69. package/dist/src/utils/llm-json-extractor.d.ts +105 -0
  70. package/dist/src/utils/llm-json-extractor.d.ts.map +1 -0
  71. package/dist/src/utils/llm-json-extractor.js +336 -0
  72. package/dist/src/utils/llm-json-extractor.js.map +1 -0
  73. package/dist/src/utils/structure-level-detector.d.ts +105 -0
  74. package/dist/src/utils/structure-level-detector.d.ts.map +1 -0
  75. package/dist/src/utils/structure-level-detector.js +388 -0
  76. package/dist/src/utils/structure-level-detector.js.map +1 -0
  77. package/dist/src/utils/validators/ado-validator.js +2 -2
  78. package/dist/src/utils/validators/ado-validator.js.map +1 -1
  79. package/package.json +1 -2
  80. package/plugins/specweave/commands/specweave-increment.md +57 -9
  81. package/plugins/specweave/commands/specweave-living-docs.md +321 -0
  82. package/plugins/specweave/commands/specweave-sync-specs.md +37 -6
  83. package/plugins/specweave/hooks/hooks.json +10 -0
  84. package/plugins/specweave/hooks/spec-project-validator.sh +111 -0
  85. package/plugins/specweave/hooks/v2/handlers/github-sync-handler.sh +10 -1
  86. package/plugins/specweave/hooks/v2/handlers/living-docs-handler.sh +10 -1
  87. package/plugins/specweave/skills/increment-planner/SKILL.md +109 -10
  88. package/plugins/specweave/skills/increment-planner/templates/spec-multi-project.md +2 -0
  89. package/plugins/specweave/skills/increment-planner/templates/spec-single-project.md +1 -0
  90. package/plugins/specweave/skills/multi-project-spec-mapper/SKILL.md +24 -1
  91. package/plugins/specweave/skills/spec-generator/SKILL.md +18 -0
  92. package/plugins/specweave/skills/specweave-framework/SKILL.md +25 -0
  93. package/plugins/specweave-ado/agents/ado-manager/AGENT.md +58 -0
  94. package/plugins/specweave-ado/commands/pull.md +30 -0
  95. package/plugins/specweave-ado/commands/push.md +30 -0
  96. package/plugins/specweave-ado/commands/sync.md +31 -0
  97. package/plugins/specweave-github/agents/github-manager/AGENT.md +22 -0
  98. package/plugins/specweave-github/hooks/.specweave/logs/hooks-debug.log +14 -0
  99. package/plugins/specweave-jira/agents/jira-manager/AGENT.md +30 -0
  100. 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
- // NOTE (2025-12-01): findNextAvailableInternalIdSync removed - internal features don't need collision check
28
- // Collision detection is only for EXTERNAL features (FS-XXXE) imported from GitHub/JIRA/ADO
29
- import { deriveFeatureId } from '../../utils/feature-id-derivation.js';
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: Get feature ID (derived from increment number)
102
- // SIMPLIFIED (v0.29.0): Feature ID is now always derived, not stored in metadata
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 (the normal case)
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 3: Parse increment spec
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-01): Internal features are DETERMINISTIC - no collision check!
210
- * - Increment 0060 ALWAYS FS-060, never anything else
211
- * - Increment 0072 ALWAYS FS-072, never anything else
212
- * - Sync is IDEMPOTENT: if FS-060 exists, UPDATE it (don't create FS-061)
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 ONLY for EXTERNAL features (FS-XXXE) during imports,
215
- * NOT for internal features derived from increment numbers.
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
- // Derive feature ID directly from increment number (e.g., "0081-name" → "FS-081")
222
- // NO collision checking - internal feature IDs are deterministic and unique by design
223
- return deriveFeatureId(incrementId);
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 name from increment spec.md
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
- * Looks for the **Project**: field in spec.md (e.g., "**Project**: digital-operation-services")
249
+ * For 2-level structures, also extracts `board:` field.
229
250
  *
230
- * SECURITY: Validates both incrementId and extracted project name to prevent path traversal
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 Project name or null if not specified or invalid
254
+ * @returns Object with project and board (null if not specified or invalid)
234
255
  */
235
- async extractProjectFromSpec(incrementId) {
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
- // Match **Project**: value or **Project:** value (with or without space after colon)
249
- const projectMatch = content.match(/\*\*Project\*\*:\s*(.+?)(?:\n|$)/i);
250
- if (projectMatch && projectMatch[1]) {
251
- // Strip markdown formatting before normalization
252
- const rawProjectName = projectMatch[1]
253
- .trim()
254
- .replace(/\*\*/g, '') // Remove bold markers
255
- .replace(/__/g, '') // Remove italic markers
256
- .replace(/`/g, ''); // Remove code markers
257
- const projectName = rawProjectName.toLowerCase().replace(/\s+/g, '-');
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
- // CRITICAL SECURITY: Block path traversal attempts
264
- // Reject names containing: .., /, \, or null bytes
265
- if (projectName.includes('..') ||
266
- projectName.includes('/') ||
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
- // Validate: only allow alphanumeric, hyphens, underscores
273
- if (!/^[a-z0-9_-]+$/.test(projectName)) {
274
- this.logger.warn(` ⚠️ Invalid project name (invalid characters): ${projectName}`);
275
- return null;
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
- // Ignore errors, return null
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
- return null;
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
- * Resolve the project path for an increment (SMART BOARD MATCHING - v0.31.0+)
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 brownfield projects imported from ADO/JIRA, uses intelligent board matching
289
- * based on increment title, description, and configured keywords.
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. Explicit **Project**: field in spec.md (always wins)
293
- * 2. Intelligent board matching using BoardMatcher (ADR-0178)
294
- * - High confidence (>80%): auto-assign
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 (may be hierarchical like "org/project")
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
- // 1. Extract project name from spec.md **Project**: field (explicit - always wins)
304
- const specProject = await this.extractProjectFromSpec(incrementId);
305
- if (specProject) {
306
- // Explicit project specified - use it directly
307
- const normalizedProject = specProject.toLowerCase().replace(/\s+/g, '-');
308
- this.logger.log(` 📎 Using explicit project from spec.md: ${normalizedProject}`);
309
- // Try to find existing hierarchical path
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, normalizedProject);
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 normalizedProject;
405
+ return project;
319
406
  }
320
- // 2. No explicit project - use intelligent board matching
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
- // Single board - use it directly
328
- // CRITICAL FIX (v0.30.13): For ADO with 2-level structure, construct {project}/{board}
329
- const board = availableBoards[0];
330
- if (board.adoProject) {
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 configured: ${board.name}`);
336
- return board.id;
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 (placeholder for future implementation)
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
- this.logger.log(` ⚠️ JIRA sync not yet implemented - skipping`);
1107
- // TODO: Implement JIRA sync when specweave-jira plugin is available
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 (placeholder for future implementation)
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
- this.logger.log(` ⚠️ ADO sync not yet implemented - skipping`);
1114
- // TODO: Implement ADO sync when specweave-ado plugin is available
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