specweave 1.0.464 → 1.0.466

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 (109) hide show
  1. package/dist/plugins/specweave/lib/vendor/core/ac-test-validator-cli.d.ts +16 -0
  2. package/dist/plugins/specweave/lib/vendor/core/ac-test-validator-cli.js +139 -0
  3. package/dist/plugins/specweave/lib/vendor/core/ac-test-validator-cli.js.map +1 -0
  4. package/dist/plugins/specweave/lib/vendor/core/ac-test-validator.d.ts +111 -0
  5. package/dist/plugins/specweave/lib/vendor/core/ac-test-validator.js +304 -0
  6. package/dist/plugins/specweave/lib/vendor/core/ac-test-validator.js.map +1 -0
  7. package/dist/plugins/specweave/lib/vendor/core/increment/ac-status-manager.d.ts +115 -0
  8. package/dist/plugins/specweave/lib/vendor/core/increment/ac-status-manager.js +359 -0
  9. package/dist/plugins/specweave/lib/vendor/core/increment/ac-status-manager.js.map +1 -0
  10. package/dist/plugins/specweave/lib/vendor/core/increment/active-increment-manager.d.ts +121 -0
  11. package/dist/plugins/specweave/lib/vendor/core/increment/active-increment-manager.js +273 -0
  12. package/dist/plugins/specweave/lib/vendor/core/increment/active-increment-manager.js.map +1 -0
  13. package/dist/plugins/specweave/lib/vendor/core/increment/auto-transition-manager.d.ts +72 -0
  14. package/dist/plugins/specweave/lib/vendor/core/increment/auto-transition-manager.js +237 -0
  15. package/dist/plugins/specweave/lib/vendor/core/increment/auto-transition-manager.js.map +1 -0
  16. package/dist/plugins/specweave/lib/vendor/core/increment/duplicate-detector.d.ts +52 -0
  17. package/dist/plugins/specweave/lib/vendor/core/increment/duplicate-detector.js +281 -0
  18. package/dist/plugins/specweave/lib/vendor/core/increment/duplicate-detector.js.map +1 -0
  19. package/dist/plugins/specweave/lib/vendor/core/increment/metadata-manager.d.ts +278 -0
  20. package/dist/plugins/specweave/lib/vendor/core/increment/metadata-manager.js +925 -0
  21. package/dist/plugins/specweave/lib/vendor/core/increment/metadata-manager.js.map +1 -0
  22. package/dist/plugins/specweave/lib/vendor/core/increment/status-auto-transition.d.ts +113 -0
  23. package/dist/plugins/specweave/lib/vendor/core/increment/status-auto-transition.js +317 -0
  24. package/dist/plugins/specweave/lib/vendor/core/increment/status-auto-transition.js.map +1 -0
  25. package/dist/plugins/specweave/lib/vendor/core/types/increment-metadata.d.ts +442 -0
  26. package/dist/plugins/specweave/lib/vendor/core/types/increment-metadata.js +246 -0
  27. package/dist/plugins/specweave/lib/vendor/core/types/increment-metadata.js.map +1 -0
  28. package/dist/plugins/specweave/lib/vendor/core/universal-auto-create.d.ts +64 -0
  29. package/dist/plugins/specweave/lib/vendor/core/universal-auto-create.js +228 -0
  30. package/dist/plugins/specweave/lib/vendor/core/universal-auto-create.js.map +1 -0
  31. package/dist/plugins/specweave/lib/vendor/generators/spec/task-parser.d.ts +95 -0
  32. package/dist/plugins/specweave/lib/vendor/generators/spec/task-parser.js +300 -0
  33. package/dist/plugins/specweave/lib/vendor/generators/spec/task-parser.js.map +1 -0
  34. package/dist/plugins/specweave/lib/vendor/sync/config.d.ts +73 -0
  35. package/dist/plugins/specweave/lib/vendor/sync/config.js +132 -0
  36. package/dist/plugins/specweave/lib/vendor/sync/config.js.map +1 -0
  37. package/dist/plugins/specweave/lib/vendor/sync/github-reconciler.d.ts +163 -0
  38. package/dist/plugins/specweave/lib/vendor/sync/github-reconciler.js +898 -0
  39. package/dist/plugins/specweave/lib/vendor/sync/github-reconciler.js.map +1 -0
  40. package/dist/plugins/specweave/lib/vendor/sync/provider-router.d.ts +86 -0
  41. package/dist/plugins/specweave/lib/vendor/sync/provider-router.js +147 -0
  42. package/dist/plugins/specweave/lib/vendor/sync/provider-router.js.map +1 -0
  43. package/dist/plugins/specweave/lib/vendor/sync/status-mapper.d.ts +120 -0
  44. package/dist/plugins/specweave/lib/vendor/sync/status-mapper.js +164 -0
  45. package/dist/plugins/specweave/lib/vendor/sync/status-mapper.js.map +1 -0
  46. package/dist/plugins/specweave/lib/vendor/utils/auth-helpers.d.ts +151 -0
  47. package/dist/plugins/specweave/lib/vendor/utils/auth-helpers.js +359 -0
  48. package/dist/plugins/specweave/lib/vendor/utils/auth-helpers.js.map +1 -0
  49. package/dist/plugins/specweave/lib/vendor/utils/chalk-fallback.d.ts +38 -0
  50. package/dist/plugins/specweave/lib/vendor/utils/chalk-fallback.js +118 -0
  51. package/dist/plugins/specweave/lib/vendor/utils/chalk-fallback.js.map +1 -0
  52. package/dist/plugins/specweave/lib/vendor/utils/clean-env.d.ts +47 -0
  53. package/dist/plugins/specweave/lib/vendor/utils/clean-env.js +63 -0
  54. package/dist/plugins/specweave/lib/vendor/utils/clean-env.js.map +1 -0
  55. package/dist/plugins/specweave/lib/vendor/utils/credential-masker.d.ts +118 -0
  56. package/dist/plugins/specweave/lib/vendor/utils/credential-masker.js +275 -0
  57. package/dist/plugins/specweave/lib/vendor/utils/credential-masker.js.map +1 -0
  58. package/dist/plugins/specweave/lib/vendor/utils/execFileNoThrow.d.ts +99 -0
  59. package/dist/plugins/specweave/lib/vendor/utils/execFileNoThrow.js +149 -0
  60. package/dist/plugins/specweave/lib/vendor/utils/execFileNoThrow.js.map +1 -0
  61. package/dist/plugins/specweave/lib/vendor/utils/feature-id-derivation.d.ts +63 -0
  62. package/dist/plugins/specweave/lib/vendor/utils/feature-id-derivation.js +85 -0
  63. package/dist/plugins/specweave/lib/vendor/utils/feature-id-derivation.js.map +1 -0
  64. package/dist/plugins/specweave/lib/vendor/utils/fs-native.d.ts +219 -0
  65. package/dist/plugins/specweave/lib/vendor/utils/fs-native.js +397 -0
  66. package/dist/plugins/specweave/lib/vendor/utils/fs-native.js.map +1 -0
  67. package/dist/plugins/specweave/lib/vendor/utils/logger.d.ts +56 -0
  68. package/dist/plugins/specweave/lib/vendor/utils/logger.js +123 -0
  69. package/dist/plugins/specweave/lib/vendor/utils/logger.js.map +1 -0
  70. package/dist/plugins/specweave/lib/vendor/utils/translation.d.ts +187 -0
  71. package/dist/plugins/specweave/lib/vendor/utils/translation.js +414 -0
  72. package/dist/plugins/specweave/lib/vendor/utils/translation.js.map +1 -0
  73. package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js +1 -1
  74. package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js.map +1 -1
  75. package/dist/plugins/specweave-ado/lib/ado-spec-sync.js +1 -1
  76. package/dist/plugins/specweave-ado/lib/ado-spec-sync.js.map +1 -1
  77. package/dist/plugins/specweave-github/lib/github-ac-checkbox-sync.js +2 -2
  78. package/dist/plugins/specweave-github/lib/github-ac-checkbox-sync.js.map +1 -1
  79. package/dist/plugins/specweave-github/lib/github-feature-sync.d.ts.map +1 -1
  80. package/dist/plugins/specweave-github/lib/github-feature-sync.js +13 -4
  81. package/dist/plugins/specweave-github/lib/github-feature-sync.js.map +1 -1
  82. package/dist/plugins/specweave-jira/lib/jira-ac-checkbox-sync.js +1 -1
  83. package/dist/plugins/specweave-jira/lib/jira-ac-checkbox-sync.js.map +1 -1
  84. package/dist/plugins/specweave-jira/lib/jira-spec-sync.js +1 -1
  85. package/dist/plugins/specweave-jira/lib/jira-spec-sync.js.map +1 -1
  86. package/dist/src/sync/spec-to-living-docs-sync.js +1 -1
  87. package/dist/src/sync/spec-to-living-docs-sync.js.map +1 -1
  88. package/package.json +1 -1
  89. package/plugins/specweave/lib/vendor/utils/auth-helpers.d.ts +151 -0
  90. package/plugins/specweave/lib/vendor/utils/auth-helpers.js +359 -0
  91. package/plugins/specweave/lib/vendor/utils/auth-helpers.js.map +1 -0
  92. package/plugins/specweave/skills/team-lead/SKILL.md +150 -56
  93. package/plugins/specweave/skills/team-lead/agents/backend.md +13 -9
  94. package/plugins/specweave/skills/team-lead/agents/database.md +13 -9
  95. package/plugins/specweave/skills/team-lead/agents/frontend.md +12 -8
  96. package/plugins/specweave/skills/team-lead/agents/security.md +13 -9
  97. package/plugins/specweave/skills/team-lead/agents/testing.md +12 -8
  98. package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js +1 -1
  99. package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.ts +1 -1
  100. package/plugins/specweave-ado/lib/ado-spec-sync.js +1 -1
  101. package/plugins/specweave-ado/lib/ado-spec-sync.ts +1 -1
  102. package/plugins/specweave-github/lib/github-ac-checkbox-sync.js +1 -1
  103. package/plugins/specweave-github/lib/github-ac-checkbox-sync.ts +2 -2
  104. package/plugins/specweave-github/lib/github-feature-sync.js +11 -3
  105. package/plugins/specweave-github/lib/github-feature-sync.ts +13 -4
  106. package/plugins/specweave-jira/lib/jira-ac-checkbox-sync.js +1 -1
  107. package/plugins/specweave-jira/lib/jira-ac-checkbox-sync.ts +1 -1
  108. package/plugins/specweave-jira/lib/jira-spec-sync.js +1 -1
  109. package/plugins/specweave-jira/lib/jira-spec-sync.ts +1 -1
@@ -0,0 +1,925 @@
1
+ /**
2
+ * Metadata Manager
3
+ *
4
+ * Handles CRUD operations for increment metadata (status, type, timestamps).
5
+ * Part of increment 0007: Smart Status Management
6
+ */
7
+ import * as fs from '../../utils/fs-native.js';
8
+ import path from 'path';
9
+ import matter from 'gray-matter';
10
+ import { IncrementStatus, IncrementType, createDefaultMetadata, isValidTransition, isStale, shouldAutoAbandon } from '../types/increment-metadata.js';
11
+ import { ActiveIncrementManager } from './active-increment-manager.js';
12
+ import { detectDuplicatesByNumber } from './duplicate-detector.js';
13
+ import { consoleLogger } from '../../utils/logger.js';
14
+ import { resolveEffectiveRoot } from '../../utils/find-project-root.js';
15
+ import { validateIncrementId } from '../../utils/increment-id-validator.js';
16
+ /**
17
+ * Error thrown when metadata operations fail
18
+ */
19
+ export class MetadataError extends Error {
20
+ constructor(message, incrementId, cause) {
21
+ super(message);
22
+ this.incrementId = incrementId;
23
+ this.cause = cause;
24
+ this.name = 'MetadataError';
25
+ }
26
+ }
27
+ /**
28
+ * Non-standard type values mapped to their canonical IncrementType equivalents.
29
+ */
30
+ const TYPE_ALIAS_MAP = {
31
+ enhancement: IncrementType.FEATURE,
32
+ improvement: IncrementType.FEATURE,
33
+ task: IncrementType.EXPERIMENT,
34
+ spike: IncrementType.EXPERIMENT,
35
+ };
36
+ /**
37
+ * Metadata Manager
38
+ *
39
+ * Provides CRUD operations and queries for increment metadata
40
+ */
41
+ export class MetadataManager {
42
+ /**
43
+ * Set logger instance (primarily for testing with silentLogger)
44
+ *
45
+ * @param logger - Logger instance to use
46
+ */
47
+ static setLogger(logger) {
48
+ this.logger = logger;
49
+ }
50
+ /**
51
+ * Get metadata file path for increment
52
+ *
53
+ * Uses resolveEffectiveRoot() to find the umbrella root in multi-repo setups,
54
+ * or the nearest project root in single-repo setups.
55
+ *
56
+ * SECURITY: Validates increment ID to prevent path traversal attacks.
57
+ */
58
+ static getMetadataPath(incrementId, rootDir) {
59
+ validateIncrementId(incrementId); // SECURITY: Prevent path traversal
60
+ const specweavePath = path.join(rootDir || resolveEffectiveRoot(), '.specweave');
61
+ return path.join(specweavePath, 'increments', incrementId, 'metadata.json');
62
+ }
63
+ /**
64
+ * Get increment directory path
65
+ *
66
+ * Uses resolveEffectiveRoot() to find the umbrella root in multi-repo setups,
67
+ * or the nearest project root in single-repo setups.
68
+ *
69
+ * SECURITY: Validates increment ID to prevent path traversal attacks.
70
+ */
71
+ static getIncrementPath(incrementId, rootDir) {
72
+ validateIncrementId(incrementId); // SECURITY: Prevent path traversal
73
+ const specweavePath = path.join(rootDir || resolveEffectiveRoot(), '.specweave');
74
+ return path.join(specweavePath, 'increments', incrementId);
75
+ }
76
+ /**
77
+ * Check if metadata file exists
78
+ */
79
+ static exists(incrementId, rootDir) {
80
+ const metadataPath = this.getMetadataPath(incrementId, rootDir);
81
+ return fs.existsSync(metadataPath);
82
+ }
83
+ /**
84
+ * Read metadata from file
85
+ * Creates default metadata if file doesn't exist (lazy initialization)
86
+ */
87
+ static read(incrementId, rootDir) {
88
+ const metadataPath = this.getMetadataPath(incrementId, rootDir);
89
+ // Lazy initialization: Create metadata if doesn't exist
90
+ if (!fs.existsSync(metadataPath)) {
91
+ // Check if increment folder exists
92
+ const incrementPath = this.getIncrementPath(incrementId, rootDir);
93
+ if (!fs.existsSync(incrementPath)) {
94
+ throw new MetadataError(`Increment not found: ${incrementId}`, incrementId);
95
+ }
96
+ // Create default metadata and run through schema validation
97
+ const raw = createDefaultMetadata(incrementId);
98
+ const { metadata: defaultMetadata, warnings } = this.validateMetadataSchema(raw, incrementId);
99
+ for (const w of warnings) {
100
+ this.logger.warn(`metadata(${incrementId}): ${w}`);
101
+ }
102
+ this.write(incrementId, defaultMetadata, rootDir);
103
+ // **CRITICAL**: Update active increment state if default status is ACTIVE
104
+ // This ensures that newly created increments are immediately tracked for status line
105
+ // Skip validation to prevent circular dependency during lazy initialization
106
+ if (defaultMetadata.status === IncrementStatus.ACTIVE) {
107
+ const activeManager = new ActiveIncrementManager();
108
+ activeManager.setActive(incrementId, true); // skipValidation = true
109
+ }
110
+ return defaultMetadata;
111
+ }
112
+ try {
113
+ const content = fs.readFileSync(metadataPath, 'utf-8');
114
+ const rawMetadata = JSON.parse(content);
115
+ // Validate & auto-correct schema issues (T-012/T-013)
116
+ const { metadata, corrected, warnings } = this.validateMetadataSchema(rawMetadata, incrementId);
117
+ // Emit warnings to stderr so they are visible but non-blocking
118
+ for (const w of warnings) {
119
+ this.logger.warn(`metadata(${incrementId}): ${w}`);
120
+ }
121
+ // Validate hard constraints (id, status enum, type enum, required fields)
122
+ this.validate(metadata);
123
+ // Write corrected file back so the fix persists
124
+ if (corrected) {
125
+ this.write(incrementId, metadata, rootDir);
126
+ }
127
+ return metadata;
128
+ }
129
+ catch (error) {
130
+ const errorMessage = error instanceof Error ? error.message : String(error);
131
+ throw new MetadataError(`Failed to read metadata for ${incrementId}: ${errorMessage}`, incrementId, error instanceof Error ? error : new Error(String(error)));
132
+ }
133
+ }
134
+ /**
135
+ * Validate increment ID is not a reserved name
136
+ * Throws if ID is reserved
137
+ */
138
+ static validateNotReserved(incrementId) {
139
+ // Check exact match
140
+ if (this.RESERVED_INCREMENT_IDS.includes(incrementId)) {
141
+ throw new MetadataError(`Invalid increment ID "${incrementId}": This is a reserved name.\n\n` +
142
+ `Reserved names include:\n` +
143
+ ` - Status values: active, backlog, paused, completed, abandoned\n` +
144
+ ` - Special folders: _archive, _templates, _config\n` +
145
+ ` - State files: active-increment, state, config\n\n` +
146
+ `Please use a descriptive name like "0035-my-feature" instead.`, incrementId);
147
+ }
148
+ // Check if it starts with underscore (reserved for special folders)
149
+ if (incrementId.startsWith('_')) {
150
+ throw new MetadataError(`Invalid increment ID "${incrementId}": Increment IDs cannot start with underscore.\n` +
151
+ `Names starting with "_" are reserved for special folders like _archive.\n\n` +
152
+ `Please use a name like "0035-my-feature" instead.`, incrementId);
153
+ }
154
+ // Check base name (before first hyphen) is not reserved
155
+ const baseName = incrementId.split('-')[0];
156
+ if (this.RESERVED_INCREMENT_IDS.includes(baseName)) {
157
+ throw new MetadataError(`Invalid increment ID "${incrementId}": Base name "${baseName}" is reserved.\n\n` +
158
+ `Please use a 4-digit number prefix like "0035-my-feature".`, incrementId);
159
+ }
160
+ }
161
+ /**
162
+ * Validate increment before creation (check for duplicates and reserved names)
163
+ * Throws if duplicates exist in other locations or ID is reserved
164
+ */
165
+ static async validateBeforeCreate(incrementId, rootDir) {
166
+ // Check for reserved names first
167
+ this.validateNotReserved(incrementId);
168
+ // Extract increment number from ID (e.g., "0033-feature-name" → "0033")
169
+ const numberMatch = incrementId.match(/^(\d+)/);
170
+ if (!numberMatch) {
171
+ throw new MetadataError(`Invalid increment ID format: ${incrementId}. Expected format: ####-name`, incrementId);
172
+ }
173
+ const incrementNumber = numberMatch[1];
174
+ // Check for duplicates
175
+ const duplicates = await detectDuplicatesByNumber(incrementNumber, rootDir || resolveEffectiveRoot());
176
+ if (duplicates.length > 0) {
177
+ const locations = duplicates.map(d => d.path).join('\n - ');
178
+ throw new MetadataError(`Cannot create increment ${incrementId}: Increment number ${incrementNumber} already exists in other location(s):\n - ${locations}\n\n` +
179
+ `Resolution options:\n` +
180
+ ` 1. Use a different increment number\n` +
181
+ ` 2. Delete/archive the existing increment(s)\n` +
182
+ ` 3. Run /sw:fix-duplicates to resolve conflicts`, incrementId);
183
+ }
184
+ }
185
+ /**
186
+ * Write metadata to file
187
+ * Uses atomic write (temp file → rename)
188
+ *
189
+ * @param incrementId - Increment ID
190
+ * @param metadata - Metadata to write
191
+ * @param rootDir - Optional root directory (defaults to process.cwd())
192
+ */
193
+ static write(incrementId, metadata, rootDir) {
194
+ const metadataPath = this.getMetadataPath(incrementId, rootDir);
195
+ const incrementPath = this.getIncrementPath(incrementId, rootDir);
196
+ // Ensure increment directory exists
197
+ if (!fs.existsSync(incrementPath)) {
198
+ throw new MetadataError(`Increment directory not found: ${incrementId}`, incrementId);
199
+ }
200
+ // CRITICAL FIX (2025-11-24): Detect if this is a NEW active increment
201
+ // When creating a new increment directly with status ACTIVE (bypassing updateStatus),
202
+ // we need to trigger living docs sync to create FS-XXX folders
203
+ // This does NOT cause double-sync because updateStatus() only calls write()
204
+ // when the file ALREADY EXISTS (for updates, not creation)
205
+ const isNewFile = !fs.existsSync(metadataPath);
206
+ const isActiveStatus = metadata.status === IncrementStatus.ACTIVE;
207
+ try {
208
+ // Validate before writing
209
+ this.validate(metadata);
210
+ // Atomic write: temp file → rename
211
+ const tempPath = `${metadataPath}.tmp`;
212
+ fs.writeFileSync(tempPath, JSON.stringify(metadata, null, 2), 'utf-8');
213
+ fs.renameSync(tempPath, metadataPath);
214
+ }
215
+ catch (error) {
216
+ const errorMessage = error instanceof Error ? error.message : String(error);
217
+ throw new MetadataError(`Failed to write metadata for ${incrementId}: ${errorMessage}`, incrementId, error instanceof Error ? error : new Error(String(error)));
218
+ }
219
+ // NEW: Trigger living docs sync for NEW active increments
220
+ // This ensures FS-XXX folders are created when increment is created with ACTIVE status
221
+ // CRITICAL FIX (2025-11-24): Only trigger if spec.md EXISTS
222
+ // In single-prompt scenarios, metadata.json is created BEFORE spec.md
223
+ // Triggering sync before spec.md exists causes "Spec file not found" error
224
+ if (isNewFile && isActiveStatus) {
225
+ const specPath = path.join(incrementPath, 'spec.md');
226
+ const specExists = fs.existsSync(specPath);
227
+ if (!specExists) {
228
+ this.logger.log(`📚 New active increment detected, but spec.md not yet created`);
229
+ this.logger.log(` Living docs sync will trigger when spec.md is ready`);
230
+ // Don't trigger sync yet - will happen via:
231
+ // 1. AutoTransitionManager.handleTasksCreated() → updateStatus() → StatusChangeSyncTrigger
232
+ // 2. Or manual /sw:sync-docs command
233
+ }
234
+ else {
235
+ this.logger.log(`📚 New active increment detected - triggering living docs sync...`);
236
+ // Non-blocking async sync (same pattern as updateStatus)
237
+ (async () => {
238
+ try {
239
+ const { LivingDocsSync } = await import('../living-docs/living-docs-sync.js');
240
+ const sync = new LivingDocsSync(rootDir || resolveEffectiveRoot(), {
241
+ logger: this.logger
242
+ });
243
+ const result = await sync.syncIncrement(incrementId);
244
+ if (result.success) {
245
+ this.logger.log(`✅ Living docs synced for ${incrementId} → ${result.featureId}`);
246
+ }
247
+ else {
248
+ this.logger.warn(`⚠️ Living docs sync completed with errors for ${incrementId}`);
249
+ }
250
+ }
251
+ catch (error) {
252
+ this.logger.error(`❌ Living docs sync failed for ${incrementId}:`, error);
253
+ this.logger.log(`💡 Run /sw:sync-docs ${incrementId} to retry`);
254
+ }
255
+ })();
256
+ }
257
+ }
258
+ }
259
+ /**
260
+ * Delete metadata file
261
+ */
262
+ static delete(incrementId, rootDir) {
263
+ const metadataPath = this.getMetadataPath(incrementId, rootDir);
264
+ if (!fs.existsSync(metadataPath)) {
265
+ return; // Already deleted
266
+ }
267
+ try {
268
+ fs.unlinkSync(metadataPath);
269
+ }
270
+ catch (error) {
271
+ const errorMessage = error instanceof Error ? error.message : String(error);
272
+ throw new MetadataError(`Failed to delete metadata for ${incrementId}: ${errorMessage}`, incrementId, error instanceof Error ? error : new Error(String(error)));
273
+ }
274
+ }
275
+ /**
276
+ * Update increment status
277
+ * Validates transition and updates timestamps
278
+ *
279
+ * **CRITICAL**: Also updates active increment state automatically!
280
+ *
281
+ * NOTE: This method is now SYNCHRONOUS to ensure spec.md is updated
282
+ * before returning. This prevents race conditions in tests and ensures
283
+ * data consistency.
284
+ */
285
+ static updateStatus(incrementId, newStatus, reason, rootDir) {
286
+ const metadata = this.read(incrementId, rootDir);
287
+ const oldStatus = metadata.status; // ← Capture old status for sync trigger
288
+ // Validate transition
289
+ if (!isValidTransition(metadata.status, newStatus)) {
290
+ throw new MetadataError(`Invalid status transition: ${metadata.status} → ${newStatus}`, incrementId);
291
+ }
292
+ // Update status
293
+ metadata.status = newStatus;
294
+ metadata.lastActivity = new Date().toISOString();
295
+ // Update status-specific fields
296
+ if (newStatus === IncrementStatus.BACKLOG) {
297
+ metadata.backlogReason = reason || 'Planned for future work';
298
+ metadata.backlogAt = new Date().toISOString();
299
+ }
300
+ else if (newStatus === IncrementStatus.PAUSED) {
301
+ metadata.pausedReason = reason || 'No reason provided';
302
+ metadata.pausedAt = new Date().toISOString();
303
+ }
304
+ else if (newStatus === IncrementStatus.ACTIVE) {
305
+ // Clear backlog/paused fields when activating
306
+ metadata.backlogReason = undefined;
307
+ metadata.backlogAt = undefined;
308
+ metadata.pausedReason = undefined;
309
+ metadata.pausedAt = undefined;
310
+ // Clear ready_for_review field if reopening
311
+ metadata.readyForReviewAt = undefined;
312
+ }
313
+ else if (newStatus === IncrementStatus.READY_FOR_REVIEW) {
314
+ // v0.28.63+: All tasks completed, awaiting user approval
315
+ metadata.readyForReviewAt = new Date().toISOString();
316
+ this.logger.log(`📋 Increment ${incrementId} ready for review - run /sw:done to close`);
317
+ }
318
+ else if (newStatus === IncrementStatus.COMPLETED) {
319
+ // v0.28.63+: User explicitly approved completion via /sw:done
320
+ metadata.approvedAt = new Date().toISOString();
321
+ }
322
+ else if (newStatus === IncrementStatus.ABANDONED) {
323
+ metadata.abandonedReason = reason || 'No reason provided';
324
+ metadata.abandonedAt = new Date().toISOString();
325
+ }
326
+ // **CRITICAL FIX (2025-11-20)**: Atomic transaction with rollback
327
+ // Update spec.md FIRST, then metadata.json. If spec.md fails, no desync occurs.
328
+ // This prevents the silent failure bug that caused increment 0047 desync.
329
+ //
330
+ // Previous bug: metadata.json updated, spec.md failed silently → desync
331
+ // New behavior: spec.md fails → error thrown → metadata.json never written
332
+ //
333
+ // AC-US2-01: updateStatus() updates both metadata.json AND spec.md frontmatter
334
+ // AC-US2-03: All status transitions update spec.md
335
+ // SYNCHRONOUS call to ensure spec.md is updated before returning
336
+ // This prevents race conditions and ensures data consistency
337
+ try {
338
+ // Step 1: Update spec.md (may throw if spec.md exists but has errors)
339
+ this.updateSpecMdStatusSync(incrementId, newStatus, rootDir);
340
+ // Step 2: Update metadata.json (only if spec.md succeeded)
341
+ this.write(incrementId, metadata, rootDir);
342
+ }
343
+ catch (error) {
344
+ // CRITICAL: spec.md update failed - prevent desync by NOT updating metadata.json
345
+ this.logger.error(`CRITICAL: Failed to update status for ${incrementId} - aborting to prevent desync`, error);
346
+ // Throw detailed error with fix instructions
347
+ throw new MetadataError(`Cannot update increment status - spec.md sync failed.\n` +
348
+ `\n` +
349
+ `This prevents source-of-truth violations (CLAUDE.md Rule #7).\n` +
350
+ `Both metadata.json AND spec.md must update atomically.\n` +
351
+ `\n` +
352
+ `Error: ${error instanceof Error ? error.message : String(error)}\n` +
353
+ `\n` +
354
+ `If this persists, check:\n` +
355
+ `1. File permissions for .specweave/increments/${incrementId}/spec.md\n` +
356
+ `2. YAML frontmatter syntax in spec.md\n` +
357
+ `3. Disk space availability\n` +
358
+ `\n` +
359
+ `To check for desyncs, run: /sw:sync-status`, incrementId, error instanceof Error ? error : undefined);
360
+ }
361
+ // **CRITICAL**: Update active increment state
362
+ const activeManager = new ActiveIncrementManager(rootDir);
363
+ if (newStatus === IncrementStatus.ACTIVE) {
364
+ // Increment became active → set as active
365
+ activeManager.setActive(incrementId);
366
+ }
367
+ else if (newStatus === IncrementStatus.COMPLETED ||
368
+ newStatus === IncrementStatus.BACKLOG ||
369
+ newStatus === IncrementStatus.PAUSED ||
370
+ newStatus === IncrementStatus.ABANDONED) {
371
+ // Increment no longer active → smart update (find next active or clear)
372
+ activeManager.smartUpdate();
373
+ }
374
+ // **NEW (2025-11-24)**: Auto-trigger sync for meaningful status transitions
375
+ // This ensures GitHub issues update automatically when work starts/completes
376
+ // Safety: Non-blocking, circuit breaker protected, errors isolated
377
+ // CRITICAL: Use async import() not require() for ESM compatibility
378
+ (async () => {
379
+ try {
380
+ // Dynamic import to avoid circular dependency
381
+ const { StatusChangeSyncTrigger } = await import('./status-change-sync-trigger.js');
382
+ await StatusChangeSyncTrigger.triggerIfNeeded(incrementId, oldStatus, newStatus).catch((error) => {
383
+ // Log but don't throw - sync failure shouldn't break status update
384
+ this.logger.warn(`Auto-sync failed for ${incrementId}: ${error.message}`);
385
+ });
386
+ }
387
+ catch (importError) {
388
+ // Module not available yet (during build?) - skip sync
389
+ this.logger.debug(`Status sync trigger not available: ${importError.message}`);
390
+ }
391
+ })(); // Execute immediately, but don't await (non-blocking)
392
+ return metadata;
393
+ }
394
+ /**
395
+ * Update spec.md status synchronously (used by updateStatus)
396
+ *
397
+ * This is a private helper to avoid async/await in updateStatus() which would
398
+ * break backward compatibility with callers expecting sync behavior.
399
+ */
400
+ static updateSpecMdStatusSync(incrementId, status, rootDir) {
401
+ // Validate status is valid enum value
402
+ if (!Object.values(IncrementStatus).includes(status)) {
403
+ throw new MetadataError(`Invalid status value: "${status}". Must be one of: ${Object.values(IncrementStatus).join(', ')}`, incrementId);
404
+ }
405
+ // Build spec.md path
406
+ const specPath = path.join(rootDir || resolveEffectiveRoot(), '.specweave', 'increments', incrementId, 'spec.md');
407
+ // Check if spec.md exists
408
+ if (!fs.existsSync(specPath)) {
409
+ // Spec doesn't exist - this is OK for legacy increments
410
+ return;
411
+ }
412
+ // Read spec.md content (synchronously)
413
+ const content = fs.readFileSync(specPath, 'utf-8');
414
+ // Parse and update YAML frontmatter using gray-matter
415
+ const parsed = matter(content);
416
+ parsed.data.status = status;
417
+ // Stringify updated content
418
+ const updatedContent = matter.stringify(parsed.content, parsed.data);
419
+ // Atomic write: temp file → rename (synchronously)
420
+ const tempPath = `${specPath}.tmp`;
421
+ fs.writeFileSync(tempPath, updatedContent, 'utf-8');
422
+ fs.renameSync(tempPath, specPath);
423
+ }
424
+ /**
425
+ * Update increment type
426
+ */
427
+ static updateType(incrementId, type) {
428
+ const metadata = this.read(incrementId);
429
+ metadata.type = type;
430
+ metadata.lastActivity = new Date().toISOString();
431
+ this.write(incrementId, metadata);
432
+ return metadata;
433
+ }
434
+ /**
435
+ * Touch increment (update lastActivity)
436
+ */
437
+ static touch(incrementId) {
438
+ const metadata = this.read(incrementId);
439
+ metadata.lastActivity = new Date().toISOString();
440
+ this.write(incrementId, metadata);
441
+ return metadata;
442
+ }
443
+ /**
444
+ * Get all increments
445
+ *
446
+ * Uses resolveEffectiveRoot() to find umbrella root in multi-repo setups.
447
+ */
448
+ static getAll() {
449
+ const incrementsPath = path.join(resolveEffectiveRoot(), '.specweave', 'increments');
450
+ if (!fs.existsSync(incrementsPath)) {
451
+ return [];
452
+ }
453
+ const incrementFolders = fs.readdirSync(incrementsPath)
454
+ .filter(name => {
455
+ const folderPath = path.join(incrementsPath, name);
456
+ return fs.statSync(folderPath).isDirectory() && !name.startsWith('_');
457
+ });
458
+ return incrementFolders
459
+ .map(folder => {
460
+ try {
461
+ return this.read(folder);
462
+ }
463
+ catch (error) {
464
+ // Skip increments with invalid/missing metadata
465
+ return null;
466
+ }
467
+ })
468
+ .filter((m) => m !== null);
469
+ }
470
+ /**
471
+ * Get increments by status
472
+ */
473
+ static getByStatus(status) {
474
+ return this.getAll().filter(m => m.status === status);
475
+ }
476
+ /**
477
+ * Get active increments (FAST: cache-first strategy)
478
+ *
479
+ * **PERFORMANCE UPGRADE**: Uses ActiveIncrementManager cache instead of scanning all increments
480
+ * - Old: Scan 31 metadata files (~50ms)
481
+ * - New: Read 1 cache file + 1-2 metadata files (~5ms) = **10x faster**
482
+ *
483
+ * Fallback to full scan if cache is stale or missing
484
+ */
485
+ static getActive() {
486
+ const activeManager = new ActiveIncrementManager();
487
+ // FAST PATH: Read from cache
488
+ const cachedIds = activeManager.getActive();
489
+ if (cachedIds.length > 0) {
490
+ // Validate cache is correct
491
+ const isValid = activeManager.validate();
492
+ if (isValid) {
493
+ // Cache is good! Read only the cached increments
494
+ return cachedIds
495
+ .map(id => {
496
+ try {
497
+ return this.read(id);
498
+ }
499
+ catch (error) {
500
+ // Stale cache entry, trigger rebuild
501
+ activeManager.smartUpdate();
502
+ return null;
503
+ }
504
+ })
505
+ .filter((m) => m !== null);
506
+ }
507
+ }
508
+ // SLOW PATH: Cache miss or invalid, scan all increments
509
+ const allActive = this.getByStatus(IncrementStatus.ACTIVE);
510
+ // Rebuild cache from scan results (DIRECTLY without calling smartUpdate to avoid circular dependency)
511
+ if (allActive.length > 0) {
512
+ // Sort by lastActivity (most recent first)
513
+ const sorted = allActive.sort((a, b) => {
514
+ const aTime = new Date(a.lastActivity).getTime();
515
+ const bTime = new Date(b.lastActivity).getTime();
516
+ return bTime - aTime; // Descending
517
+ });
518
+ // Take max 2 and write state directly
519
+ const activeIds = sorted.slice(0, 2).map(m => m.id);
520
+ const state = {
521
+ ids: activeIds,
522
+ lastUpdated: new Date().toISOString()
523
+ };
524
+ // Write state directly using private method (copy logic from ActiveIncrementManager.writeState)
525
+ const stateFile = activeManager.getStateFilePath();
526
+ const stateDir = path.dirname(stateFile);
527
+ if (!fs.existsSync(stateDir)) {
528
+ fs.mkdirSync(stateDir, { recursive: true });
529
+ }
530
+ const tempFile = `${stateFile}.tmp`;
531
+ fs.writeFileSync(tempFile, JSON.stringify(state, null, 2), 'utf-8');
532
+ fs.renameSync(tempFile, stateFile);
533
+ }
534
+ else {
535
+ // No active increments, clear cache
536
+ activeManager.clearActive();
537
+ }
538
+ return allActive;
539
+ }
540
+ /**
541
+ * Get backlog increments
542
+ */
543
+ static getBacklog() {
544
+ return this.getByStatus(IncrementStatus.BACKLOG);
545
+ }
546
+ /**
547
+ * Get paused increments
548
+ */
549
+ static getPaused() {
550
+ return this.getByStatus(IncrementStatus.PAUSED);
551
+ }
552
+ /**
553
+ * Get completed increments
554
+ */
555
+ static getCompleted() {
556
+ return this.getByStatus(IncrementStatus.COMPLETED);
557
+ }
558
+ /**
559
+ * Get abandoned increments
560
+ */
561
+ static getAbandoned() {
562
+ return this.getByStatus(IncrementStatus.ABANDONED);
563
+ }
564
+ /**
565
+ * Get increments by type
566
+ */
567
+ static getByType(type) {
568
+ return this.getAll().filter(m => m.type === type);
569
+ }
570
+ /**
571
+ * Get stale increments (paused >7 days or active >30 days)
572
+ */
573
+ static getStale() {
574
+ return this.getAll().filter(m => isStale(m));
575
+ }
576
+ /**
577
+ * Get increments that should be auto-abandoned (experiments inactive >14 days)
578
+ */
579
+ static getShouldAutoAbandon() {
580
+ return this.getAll().filter(m => shouldAutoAbandon(m));
581
+ }
582
+ /**
583
+ * Get extended metadata with computed fields (progress, age, etc.)
584
+ */
585
+ static getExtended(incrementId) {
586
+ const metadata = this.read(incrementId);
587
+ const extended = { ...metadata };
588
+ // Calculate progress from tasks.md
589
+ try {
590
+ const tasksPath = path.join(this.getIncrementPath(incrementId), 'tasks.md');
591
+ if (fs.existsSync(tasksPath)) {
592
+ const tasksContent = fs.readFileSync(tasksPath, 'utf-8');
593
+ // Count completed tasks: [x] or [X]
594
+ const completedMatches = tasksContent.match(/\[x\]/gi);
595
+ extended.completedTasks = completedMatches ? completedMatches.length : 0;
596
+ // Count total tasks: [ ] or [x]
597
+ const totalMatches = tasksContent.match(/\[ \]|\[x\]/gi);
598
+ extended.totalTasks = totalMatches ? totalMatches.length : 0;
599
+ // Calculate progress percentage
600
+ if (extended.totalTasks > 0) {
601
+ extended.progress = Math.round((extended.completedTasks / extended.totalTasks) * 100);
602
+ }
603
+ }
604
+ }
605
+ catch (error) {
606
+ // Ignore errors reading tasks.md
607
+ }
608
+ // Calculate age in days
609
+ const now = new Date();
610
+ const createdDate = new Date(metadata.created);
611
+ extended.ageInDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
612
+ // Calculate days paused
613
+ if (metadata.status === IncrementStatus.PAUSED && metadata.pausedAt) {
614
+ const pausedDate = new Date(metadata.pausedAt);
615
+ extended.daysPaused = Math.floor((now.getTime() - pausedDate.getTime()) / (1000 * 60 * 60 * 24));
616
+ }
617
+ return extended;
618
+ }
619
+ /**
620
+ * Validate and auto-correct metadata schema issues.
621
+ *
622
+ * Fixes:
623
+ * - id: short numeric IDs (e.g. "0399") expanded to full slug using folderName
624
+ * - type: non-standard aliases mapped to canonical enum values
625
+ * - created/createdAt: legacy field renamed
626
+ * - externalLinks: ensured to exist (defaults to {})
627
+ * - status/priority/testMode/coverageTarget: sensible defaults applied
628
+ *
629
+ * @returns corrected metadata, whether any correction was made, and warnings
630
+ */
631
+ static validateMetadataSchema(raw, folderName) {
632
+ const warnings = [];
633
+ let corrected = false;
634
+ const metadata = { ...raw };
635
+ // --- id: expand short numeric-only IDs to full slug ---
636
+ if (metadata.id && /^\d{4}[A-Za-z]?$/.test(metadata.id) && folderName) {
637
+ warnings.push(`Short ID '${metadata.id}' expanded to full slug '${folderName}'`);
638
+ metadata.id = folderName;
639
+ corrected = true;
640
+ }
641
+ // --- type: map non-standard aliases ---
642
+ if (metadata.type) {
643
+ const alias = TYPE_ALIAS_MAP[metadata.type];
644
+ if (alias) {
645
+ warnings.push(`Non-standard type '${metadata.type}' mapped to '${alias}'`);
646
+ metadata.type = alias;
647
+ corrected = true;
648
+ }
649
+ else if (!Object.values(IncrementType).includes(metadata.type)) {
650
+ warnings.push(`Unknown type '${metadata.type}' — keeping as-is`);
651
+ }
652
+ }
653
+ // --- createdAt → created ---
654
+ if (!metadata.created && metadata.createdAt) {
655
+ metadata.created = metadata.createdAt;
656
+ delete metadata.createdAt;
657
+ warnings.push(`Renamed legacy field 'createdAt' to 'created'`);
658
+ corrected = true;
659
+ }
660
+ else if (metadata.createdAt) {
661
+ // Both exist — drop the legacy one
662
+ delete metadata.createdAt;
663
+ corrected = true;
664
+ }
665
+ // --- updatedAt → updated (bonus, same pattern already in read()) ---
666
+ if (metadata.updatedAt) {
667
+ if (!metadata.updated) {
668
+ metadata.updated = metadata.updatedAt;
669
+ }
670
+ delete metadata.updatedAt;
671
+ corrected = true;
672
+ }
673
+ // --- externalLinks default ---
674
+ if (!metadata.externalLinks) {
675
+ metadata.externalLinks = {};
676
+ corrected = true;
677
+ }
678
+ // --- normalizeExternalLinks: copy legacy github → externalLinks.github ---
679
+ const elGithub = metadata.externalLinks?.github;
680
+ const hasNewGithubData = elGithub && elGithub.issues && Object.keys(elGithub.issues).length > 0;
681
+ const oldGithub = metadata.github;
682
+ const hasOldGithubData = oldGithub && Array.isArray(oldGithub.issues) && oldGithub.issues.length > 0;
683
+ if (!hasNewGithubData && hasOldGithubData) {
684
+ const issues = {};
685
+ for (const issue of oldGithub.issues) {
686
+ if (issue.userStory && issue.number) {
687
+ issues[issue.userStory] = {
688
+ issueNumber: issue.number,
689
+ issueUrl: issue.url || '',
690
+ status: 'active',
691
+ };
692
+ }
693
+ }
694
+ if (Object.keys(issues).length > 0) {
695
+ metadata.externalLinks.github = {
696
+ issues,
697
+ ...(oldGithub.milestone != null ? { milestone: oldGithub.milestone } : {}),
698
+ syncedAt: oldGithub.lastSync || new Date().toISOString(),
699
+ };
700
+ warnings.push('Copied legacy github data to externalLinks.github');
701
+ corrected = true;
702
+ }
703
+ }
704
+ // --- sensible defaults ---
705
+ if (!metadata.status) {
706
+ metadata.status = IncrementStatus.PLANNING;
707
+ warnings.push(`Missing 'status' — defaulted to '${IncrementStatus.PLANNING}'`);
708
+ corrected = true;
709
+ }
710
+ if (!metadata.priority) {
711
+ metadata.priority = 'medium';
712
+ corrected = true;
713
+ }
714
+ if (!metadata.created) {
715
+ metadata.created = new Date().toISOString();
716
+ corrected = true;
717
+ }
718
+ if (!metadata.lastActivity) {
719
+ metadata.lastActivity = metadata.updated || metadata.created;
720
+ corrected = true;
721
+ }
722
+ if (metadata.testMode === undefined) {
723
+ metadata.testMode = 'test-after';
724
+ corrected = true;
725
+ }
726
+ if (metadata.coverageTarget === undefined) {
727
+ metadata.coverageTarget = 80;
728
+ corrected = true;
729
+ }
730
+ if (!metadata.type) {
731
+ metadata.type = IncrementType.FEATURE;
732
+ warnings.push(`Missing 'type' — defaulted to '${IncrementType.FEATURE}'`);
733
+ corrected = true;
734
+ }
735
+ return { metadata, corrected, warnings };
736
+ }
737
+ /**
738
+ * Validate metadata schema
739
+ */
740
+ static validate(metadata) {
741
+ if (!metadata.id) {
742
+ throw new Error('Metadata missing required field: id');
743
+ }
744
+ if (!metadata.status || !Object.values(IncrementStatus).includes(metadata.status)) {
745
+ throw new Error(`Invalid status: ${metadata.status}`);
746
+ }
747
+ if (!metadata.type || !Object.values(IncrementType).includes(metadata.type)) {
748
+ throw new Error(`Invalid type: ${metadata.type}`);
749
+ }
750
+ if (!metadata.created) {
751
+ throw new Error('Metadata missing required field: created');
752
+ }
753
+ if (!metadata.lastActivity) {
754
+ throw new Error('Metadata missing required field: lastActivity');
755
+ }
756
+ return true;
757
+ }
758
+ /**
759
+ * Check if status transition is allowed
760
+ */
761
+ static canTransition(from, to) {
762
+ return isValidTransition(from, to);
763
+ }
764
+ /**
765
+ * Get human-readable status transition error message
766
+ */
767
+ static getTransitionError(from, to) {
768
+ if (from === IncrementStatus.COMPLETED) {
769
+ return `Cannot transition from completed state. Increment is already complete.`;
770
+ }
771
+ if (to === IncrementStatus.PAUSED && from === IncrementStatus.ABANDONED) {
772
+ return `Cannot pause an abandoned increment. Resume it first with /resume.`;
773
+ }
774
+ if (to === IncrementStatus.COMPLETED && from === IncrementStatus.ABANDONED) {
775
+ return `Cannot complete an abandoned increment. Resume it first with /resume.`;
776
+ }
777
+ return `Invalid transition: ${from} → ${to}`;
778
+ }
779
+ // ==========================================================================
780
+ // Sync Target Methods (v1.0.31+ - ADR-0211)
781
+ // ==========================================================================
782
+ /**
783
+ * Set the external tool sync target for an increment
784
+ *
785
+ * This explicitly specifies which sync profile the increment uses.
786
+ * The sync target provides audit trail and deterministic sync behavior.
787
+ *
788
+ * @param incrementId - Increment ID
789
+ * @param syncTarget - Sync target configuration
790
+ * @param rootDir - Optional root directory
791
+ * @returns Updated metadata
792
+ *
793
+ * @example
794
+ * ```typescript
795
+ * MetadataManager.setSyncTarget('0142-feature', {
796
+ * profileId: 'github-frontend',
797
+ * provider: 'github',
798
+ * derivedFrom: 'project-mapping',
799
+ * setAt: new Date().toISOString(),
800
+ * sourceProjectId: 'frontend-app'
801
+ * });
802
+ * ```
803
+ */
804
+ static setSyncTarget(incrementId, syncTarget, rootDir) {
805
+ const metadata = this.read(incrementId, rootDir);
806
+ metadata.syncTarget = syncTarget;
807
+ metadata.lastActivity = new Date().toISOString();
808
+ this.write(incrementId, metadata, rootDir);
809
+ this.logger.debug(`Set sync target for ${incrementId}: ${syncTarget.profileId} (${syncTarget.provider})`);
810
+ return metadata;
811
+ }
812
+ /**
813
+ * Get the sync target for an increment
814
+ *
815
+ * @param incrementId - Increment ID
816
+ * @param rootDir - Optional root directory
817
+ * @returns Sync target or undefined if not set
818
+ */
819
+ static getSyncTarget(incrementId, rootDir) {
820
+ const metadata = this.read(incrementId, rootDir);
821
+ return metadata.syncTarget;
822
+ }
823
+ /**
824
+ * Clear the sync target for an increment
825
+ *
826
+ * Use this when the external tool configuration changes and
827
+ * the increment needs to be re-resolved.
828
+ *
829
+ * @param incrementId - Increment ID
830
+ * @param rootDir - Optional root directory
831
+ * @returns Updated metadata
832
+ */
833
+ static clearSyncTarget(incrementId, rootDir) {
834
+ const metadata = this.read(incrementId, rootDir);
835
+ delete metadata.syncTarget;
836
+ metadata.lastActivity = new Date().toISOString();
837
+ this.write(incrementId, metadata, rootDir);
838
+ this.logger.debug(`Cleared sync target for ${incrementId}`);
839
+ return metadata;
840
+ }
841
+ /**
842
+ * Check if increment has a sync target configured
843
+ *
844
+ * @param incrementId - Increment ID
845
+ * @param rootDir - Optional root directory
846
+ * @returns true if sync target is set
847
+ */
848
+ static hasSyncTarget(incrementId, rootDir) {
849
+ const metadata = this.read(incrementId, rootDir);
850
+ return !!metadata.syncTarget;
851
+ }
852
+ /**
853
+ * Add a pull request reference to an increment (v1.0.437+)
854
+ *
855
+ * Appends a PrRef to the prRefs array. Deduplicates by repoSlug
856
+ * (or by branch name for single-repo increments without repoSlug).
857
+ *
858
+ * @param incrementId - Increment ID
859
+ * @param ref - Pull request reference to add
860
+ * @param rootDir - Optional root directory
861
+ * @returns Updated metadata
862
+ */
863
+ static addPrRef(incrementId, ref, rootDir) {
864
+ const metadata = this.read(incrementId, rootDir);
865
+ const existing = metadata.prRefs || [];
866
+ // Deduplicate by repoSlug (multi-repo) or branch (single-repo)
867
+ const key = ref.repoSlug || ref.branch;
868
+ const filtered = existing.filter(r => (r.repoSlug || r.branch) !== key);
869
+ metadata.prRefs = [...filtered, ref];
870
+ metadata.lastActivity = new Date().toISOString();
871
+ this.write(incrementId, metadata, rootDir);
872
+ this.logger.debug(`Added PR ref for ${incrementId}: ${ref.branch} → ${ref.prUrl || '(no URL yet)'}`);
873
+ return metadata;
874
+ }
875
+ /**
876
+ * Get pull request references for an increment
877
+ *
878
+ * @param incrementId - Increment ID
879
+ * @param rootDir - Optional root directory
880
+ * @returns Array of PR refs, or empty array if none
881
+ */
882
+ static getPrRefs(incrementId, rootDir) {
883
+ const metadata = this.read(incrementId, rootDir);
884
+ return metadata.prRefs || [];
885
+ }
886
+ /**
887
+ * Get all increments with a specific sync provider
888
+ *
889
+ * Useful for bulk operations on all GitHub/JIRA/ADO synced increments.
890
+ *
891
+ * @param provider - Provider type ('github', 'jira', 'ado')
892
+ * @returns Array of increments with that provider configured
893
+ */
894
+ static getByProvider(provider) {
895
+ return this.getAll().filter(m => m.syncTarget?.provider === provider);
896
+ }
897
+ /**
898
+ * Get all increments with a specific sync profile
899
+ *
900
+ * @param profileId - Profile ID from config.sync.profiles
901
+ * @returns Array of increments using that profile
902
+ */
903
+ static getByProfile(profileId) {
904
+ return this.getAll().filter(m => m.syncTarget?.profileId === profileId);
905
+ }
906
+ }
907
+ /**
908
+ * Logger instance (injectable for testing)
909
+ */
910
+ MetadataManager.logger = consoleLogger;
911
+ /**
912
+ * Reserved increment IDs that cannot be used
913
+ * These are status values, special folders, and state files
914
+ */
915
+ MetadataManager.RESERVED_INCREMENT_IDS = [
916
+ // Status values (would confuse state management)
917
+ 'active', 'backlog', 'paused', 'completed', 'abandoned',
918
+ // Special folders (file system conflicts)
919
+ '_archive', '_templates', '_config',
920
+ // State files (would overwrite critical files)
921
+ 'active-increment', 'state', 'config',
922
+ // Common terms that should not be IDs
923
+ 'current', 'latest', 'new', 'temp', 'test'
924
+ ];
925
+ //# sourceMappingURL=metadata-manager.js.map