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.
- package/dist/plugins/specweave/lib/vendor/core/ac-test-validator-cli.d.ts +16 -0
- package/dist/plugins/specweave/lib/vendor/core/ac-test-validator-cli.js +139 -0
- package/dist/plugins/specweave/lib/vendor/core/ac-test-validator-cli.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/ac-test-validator.d.ts +111 -0
- package/dist/plugins/specweave/lib/vendor/core/ac-test-validator.js +304 -0
- package/dist/plugins/specweave/lib/vendor/core/ac-test-validator.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/ac-status-manager.d.ts +115 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/ac-status-manager.js +359 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/ac-status-manager.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/active-increment-manager.d.ts +121 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/active-increment-manager.js +273 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/active-increment-manager.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/auto-transition-manager.d.ts +72 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/auto-transition-manager.js +237 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/auto-transition-manager.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/duplicate-detector.d.ts +52 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/duplicate-detector.js +281 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/duplicate-detector.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/metadata-manager.d.ts +278 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/metadata-manager.js +925 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/metadata-manager.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/status-auto-transition.d.ts +113 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/status-auto-transition.js +317 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/status-auto-transition.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/types/increment-metadata.d.ts +442 -0
- package/dist/plugins/specweave/lib/vendor/core/types/increment-metadata.js +246 -0
- package/dist/plugins/specweave/lib/vendor/core/types/increment-metadata.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/universal-auto-create.d.ts +64 -0
- package/dist/plugins/specweave/lib/vendor/core/universal-auto-create.js +228 -0
- package/dist/plugins/specweave/lib/vendor/core/universal-auto-create.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/generators/spec/task-parser.d.ts +95 -0
- package/dist/plugins/specweave/lib/vendor/generators/spec/task-parser.js +300 -0
- package/dist/plugins/specweave/lib/vendor/generators/spec/task-parser.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/sync/config.d.ts +73 -0
- package/dist/plugins/specweave/lib/vendor/sync/config.js +132 -0
- package/dist/plugins/specweave/lib/vendor/sync/config.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/sync/github-reconciler.d.ts +163 -0
- package/dist/plugins/specweave/lib/vendor/sync/github-reconciler.js +898 -0
- package/dist/plugins/specweave/lib/vendor/sync/github-reconciler.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/sync/provider-router.d.ts +86 -0
- package/dist/plugins/specweave/lib/vendor/sync/provider-router.js +147 -0
- package/dist/plugins/specweave/lib/vendor/sync/provider-router.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/sync/status-mapper.d.ts +120 -0
- package/dist/plugins/specweave/lib/vendor/sync/status-mapper.js +164 -0
- package/dist/plugins/specweave/lib/vendor/sync/status-mapper.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/auth-helpers.d.ts +151 -0
- package/dist/plugins/specweave/lib/vendor/utils/auth-helpers.js +359 -0
- package/dist/plugins/specweave/lib/vendor/utils/auth-helpers.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/chalk-fallback.d.ts +38 -0
- package/dist/plugins/specweave/lib/vendor/utils/chalk-fallback.js +118 -0
- package/dist/plugins/specweave/lib/vendor/utils/chalk-fallback.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/clean-env.d.ts +47 -0
- package/dist/plugins/specweave/lib/vendor/utils/clean-env.js +63 -0
- package/dist/plugins/specweave/lib/vendor/utils/clean-env.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/credential-masker.d.ts +118 -0
- package/dist/plugins/specweave/lib/vendor/utils/credential-masker.js +275 -0
- package/dist/plugins/specweave/lib/vendor/utils/credential-masker.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/execFileNoThrow.d.ts +99 -0
- package/dist/plugins/specweave/lib/vendor/utils/execFileNoThrow.js +149 -0
- package/dist/plugins/specweave/lib/vendor/utils/execFileNoThrow.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/feature-id-derivation.d.ts +63 -0
- package/dist/plugins/specweave/lib/vendor/utils/feature-id-derivation.js +85 -0
- package/dist/plugins/specweave/lib/vendor/utils/feature-id-derivation.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/fs-native.d.ts +219 -0
- package/dist/plugins/specweave/lib/vendor/utils/fs-native.js +397 -0
- package/dist/plugins/specweave/lib/vendor/utils/fs-native.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/logger.d.ts +56 -0
- package/dist/plugins/specweave/lib/vendor/utils/logger.js +123 -0
- package/dist/plugins/specweave/lib/vendor/utils/logger.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/translation.d.ts +187 -0
- package/dist/plugins/specweave/lib/vendor/utils/translation.js +414 -0
- package/dist/plugins/specweave/lib/vendor/utils/translation.js.map +1 -0
- package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js +1 -1
- package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js.map +1 -1
- package/dist/plugins/specweave-ado/lib/ado-spec-sync.js +1 -1
- package/dist/plugins/specweave-ado/lib/ado-spec-sync.js.map +1 -1
- package/dist/plugins/specweave-github/lib/github-ac-checkbox-sync.js +2 -2
- package/dist/plugins/specweave-github/lib/github-ac-checkbox-sync.js.map +1 -1
- package/dist/plugins/specweave-github/lib/github-feature-sync.d.ts.map +1 -1
- package/dist/plugins/specweave-github/lib/github-feature-sync.js +13 -4
- package/dist/plugins/specweave-github/lib/github-feature-sync.js.map +1 -1
- package/dist/plugins/specweave-jira/lib/jira-ac-checkbox-sync.js +1 -1
- package/dist/plugins/specweave-jira/lib/jira-ac-checkbox-sync.js.map +1 -1
- package/dist/plugins/specweave-jira/lib/jira-spec-sync.js +1 -1
- package/dist/plugins/specweave-jira/lib/jira-spec-sync.js.map +1 -1
- package/dist/src/sync/spec-to-living-docs-sync.js +1 -1
- package/dist/src/sync/spec-to-living-docs-sync.js.map +1 -1
- package/package.json +1 -1
- package/plugins/specweave/lib/vendor/utils/auth-helpers.d.ts +151 -0
- package/plugins/specweave/lib/vendor/utils/auth-helpers.js +359 -0
- package/plugins/specweave/lib/vendor/utils/auth-helpers.js.map +1 -0
- package/plugins/specweave/skills/team-lead/SKILL.md +150 -56
- package/plugins/specweave/skills/team-lead/agents/backend.md +13 -9
- package/plugins/specweave/skills/team-lead/agents/database.md +13 -9
- package/plugins/specweave/skills/team-lead/agents/frontend.md +12 -8
- package/plugins/specweave/skills/team-lead/agents/security.md +13 -9
- package/plugins/specweave/skills/team-lead/agents/testing.md +12 -8
- package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js +1 -1
- package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.ts +1 -1
- package/plugins/specweave-ado/lib/ado-spec-sync.js +1 -1
- package/plugins/specweave-ado/lib/ado-spec-sync.ts +1 -1
- package/plugins/specweave-github/lib/github-ac-checkbox-sync.js +1 -1
- package/plugins/specweave-github/lib/github-ac-checkbox-sync.ts +2 -2
- package/plugins/specweave-github/lib/github-feature-sync.js +11 -3
- package/plugins/specweave-github/lib/github-feature-sync.ts +13 -4
- package/plugins/specweave-jira/lib/jira-ac-checkbox-sync.js +1 -1
- package/plugins/specweave-jira/lib/jira-ac-checkbox-sync.ts +1 -1
- package/plugins/specweave-jira/lib/jira-spec-sync.js +1 -1
- 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
|