@fission-ai/openspec 0.1.0
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/README.md +119 -0
- package/bin/openspec.js +3 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +240 -0
- package/dist/commands/change.d.ts +35 -0
- package/dist/commands/change.js +276 -0
- package/dist/commands/show.d.ts +14 -0
- package/dist/commands/show.js +131 -0
- package/dist/commands/spec.d.ts +15 -0
- package/dist/commands/spec.js +224 -0
- package/dist/commands/validate.d.ts +23 -0
- package/dist/commands/validate.js +275 -0
- package/dist/core/archive.d.ts +15 -0
- package/dist/core/archive.js +529 -0
- package/dist/core/config.d.ts +14 -0
- package/dist/core/config.js +12 -0
- package/dist/core/configurators/base.d.ts +7 -0
- package/dist/core/configurators/base.js +2 -0
- package/dist/core/configurators/claude.d.ts +8 -0
- package/dist/core/configurators/claude.js +15 -0
- package/dist/core/configurators/registry.d.ts +9 -0
- package/dist/core/configurators/registry.js +22 -0
- package/dist/core/converters/json-converter.d.ts +6 -0
- package/dist/core/converters/json-converter.js +48 -0
- package/dist/core/diff.d.ts +11 -0
- package/dist/core/diff.js +193 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/init.d.ts +10 -0
- package/dist/core/init.js +109 -0
- package/dist/core/list.d.ts +4 -0
- package/dist/core/list.js +89 -0
- package/dist/core/parsers/change-parser.d.ts +13 -0
- package/dist/core/parsers/change-parser.js +192 -0
- package/dist/core/parsers/markdown-parser.d.ts +21 -0
- package/dist/core/parsers/markdown-parser.js +183 -0
- package/dist/core/parsers/requirement-blocks.d.ts +31 -0
- package/dist/core/parsers/requirement-blocks.js +173 -0
- package/dist/core/schemas/base.schema.d.ts +13 -0
- package/dist/core/schemas/base.schema.js +13 -0
- package/dist/core/schemas/change.schema.d.ts +73 -0
- package/dist/core/schemas/change.schema.js +31 -0
- package/dist/core/schemas/index.d.ts +4 -0
- package/dist/core/schemas/index.js +4 -0
- package/dist/core/schemas/spec.schema.d.ts +18 -0
- package/dist/core/schemas/spec.schema.js +15 -0
- package/dist/core/templates/claude-template.d.ts +2 -0
- package/dist/core/templates/claude-template.js +96 -0
- package/dist/core/templates/index.d.ts +11 -0
- package/dist/core/templates/index.js +21 -0
- package/dist/core/templates/project-template.d.ts +8 -0
- package/dist/core/templates/project-template.js +32 -0
- package/dist/core/templates/readme-template.d.ts +2 -0
- package/dist/core/templates/readme-template.js +519 -0
- package/dist/core/update.d.ts +4 -0
- package/dist/core/update.js +47 -0
- package/dist/core/validation/constants.d.ts +34 -0
- package/dist/core/validation/constants.js +40 -0
- package/dist/core/validation/types.d.ts +18 -0
- package/dist/core/validation/types.js +2 -0
- package/dist/core/validation/validator.d.ts +32 -0
- package/dist/core/validation/validator.js +355 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/utils/file-system.d.ts +10 -0
- package/dist/utils/file-system.js +83 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.js +2 -0
- package/dist/utils/interactive.d.ts +2 -0
- package/dist/utils/interactive.js +8 -0
- package/dist/utils/item-discovery.d.ts +3 -0
- package/dist/utils/item-discovery.js +49 -0
- package/dist/utils/match.d.ts +3 -0
- package/dist/utils/match.js +22 -0
- package/dist/utils/task-progress.d.ts +8 -0
- package/dist/utils/task-progress.js +36 -0
- package/package.json +68 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare class ArchiveCommand {
|
|
2
|
+
execute(changeName?: string, options?: {
|
|
3
|
+
yes?: boolean;
|
|
4
|
+
skipSpecs?: boolean;
|
|
5
|
+
noValidate?: boolean;
|
|
6
|
+
}): Promise<void>;
|
|
7
|
+
private selectChange;
|
|
8
|
+
private checkIncompleteTasks;
|
|
9
|
+
private findSpecUpdates;
|
|
10
|
+
private buildUpdatedSpec;
|
|
11
|
+
private writeUpdatedSpec;
|
|
12
|
+
private buildSpecSkeleton;
|
|
13
|
+
private getArchiveDate;
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=archive.d.ts.map
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { select, confirm } from '@inquirer/prompts';
|
|
4
|
+
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
|
|
5
|
+
import { Validator } from './validation/validator.js';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { extractRequirementsSection, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
|
|
8
|
+
export class ArchiveCommand {
|
|
9
|
+
async execute(changeName, options = {}) {
|
|
10
|
+
const targetPath = '.';
|
|
11
|
+
const changesDir = path.join(targetPath, 'openspec', 'changes');
|
|
12
|
+
const archiveDir = path.join(changesDir, 'archive');
|
|
13
|
+
const mainSpecsDir = path.join(targetPath, 'openspec', 'specs');
|
|
14
|
+
// Check if changes directory exists
|
|
15
|
+
try {
|
|
16
|
+
await fs.access(changesDir);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new Error("No OpenSpec changes directory found. Run 'openspec init' first.");
|
|
20
|
+
}
|
|
21
|
+
// Get change name interactively if not provided
|
|
22
|
+
if (!changeName) {
|
|
23
|
+
const selectedChange = await this.selectChange(changesDir);
|
|
24
|
+
if (!selectedChange) {
|
|
25
|
+
console.log('No change selected. Aborting.');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
changeName = selectedChange;
|
|
29
|
+
}
|
|
30
|
+
const changeDir = path.join(changesDir, changeName);
|
|
31
|
+
// Verify change exists
|
|
32
|
+
try {
|
|
33
|
+
const stat = await fs.stat(changeDir);
|
|
34
|
+
if (!stat.isDirectory()) {
|
|
35
|
+
throw new Error(`Change '${changeName}' not found.`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error(`Change '${changeName}' not found.`);
|
|
40
|
+
}
|
|
41
|
+
// Validate specs and change before archiving
|
|
42
|
+
if (!options.noValidate) {
|
|
43
|
+
const validator = new Validator();
|
|
44
|
+
let hasValidationErrors = false;
|
|
45
|
+
// Validate proposal.md (non-blocking unless strict mode desired in future)
|
|
46
|
+
const changeFile = path.join(changeDir, 'proposal.md');
|
|
47
|
+
try {
|
|
48
|
+
await fs.access(changeFile);
|
|
49
|
+
const changeReport = await validator.validateChange(changeFile);
|
|
50
|
+
// Proposal validation is informative only (do not block archive)
|
|
51
|
+
if (!changeReport.valid) {
|
|
52
|
+
console.log(chalk.yellow(`\nProposal warnings in proposal.md (non-blocking):`));
|
|
53
|
+
for (const issue of changeReport.issues) {
|
|
54
|
+
const symbol = issue.level === 'ERROR' ? '⚠' : (issue.level === 'WARNING' ? '⚠' : 'ℹ');
|
|
55
|
+
console.log(chalk.yellow(` ${symbol} ${issue.message}`));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Change file doesn't exist, skip validation
|
|
61
|
+
}
|
|
62
|
+
// Validate delta-formatted spec files under the change directory if present
|
|
63
|
+
const changeSpecsDir = path.join(changeDir, 'specs');
|
|
64
|
+
let hasDeltaSpecs = false;
|
|
65
|
+
try {
|
|
66
|
+
const candidates = await fs.readdir(changeSpecsDir, { withFileTypes: true });
|
|
67
|
+
for (const c of candidates) {
|
|
68
|
+
if (c.isDirectory()) {
|
|
69
|
+
try {
|
|
70
|
+
const candidatePath = path.join(changeSpecsDir, c.name, 'spec.md');
|
|
71
|
+
await fs.access(candidatePath);
|
|
72
|
+
const content = await fs.readFile(candidatePath, 'utf-8');
|
|
73
|
+
if (/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements/m.test(content)) {
|
|
74
|
+
hasDeltaSpecs = true;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch { }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch { }
|
|
83
|
+
if (hasDeltaSpecs) {
|
|
84
|
+
const deltaReport = await validator.validateChangeDeltaSpecs(changeDir);
|
|
85
|
+
if (!deltaReport.valid) {
|
|
86
|
+
hasValidationErrors = true;
|
|
87
|
+
console.log(chalk.red(`\nValidation errors in change delta specs:`));
|
|
88
|
+
for (const issue of deltaReport.issues) {
|
|
89
|
+
if (issue.level === 'ERROR') {
|
|
90
|
+
console.log(chalk.red(` ✗ ${issue.message}`));
|
|
91
|
+
}
|
|
92
|
+
else if (issue.level === 'WARNING') {
|
|
93
|
+
console.log(chalk.yellow(` ⚠ ${issue.message}`));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (hasValidationErrors) {
|
|
99
|
+
console.log(chalk.red('\nValidation failed. Please fix the errors before archiving.'));
|
|
100
|
+
console.log(chalk.yellow('To skip validation (not recommended), use --no-validate flag.'));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
// Log warning when validation is skipped
|
|
106
|
+
const timestamp = new Date().toISOString();
|
|
107
|
+
if (!options.yes) {
|
|
108
|
+
const proceed = await confirm({
|
|
109
|
+
message: chalk.yellow('⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)'),
|
|
110
|
+
default: false
|
|
111
|
+
});
|
|
112
|
+
if (!proceed) {
|
|
113
|
+
console.log('Archive cancelled.');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
console.log(chalk.yellow(`\n⚠️ WARNING: Skipping validation may archive invalid specs.`));
|
|
119
|
+
}
|
|
120
|
+
console.log(chalk.yellow(`[${timestamp}] Validation skipped for change: ${changeName}`));
|
|
121
|
+
console.log(chalk.yellow(`Affected files: ${changeDir}`));
|
|
122
|
+
}
|
|
123
|
+
// Show progress and check for incomplete tasks
|
|
124
|
+
const progress = await getTaskProgressForChange(changesDir, changeName);
|
|
125
|
+
const status = formatTaskStatus(progress);
|
|
126
|
+
console.log(`Task status: ${status}`);
|
|
127
|
+
const incompleteTasks = Math.max(progress.total - progress.completed, 0);
|
|
128
|
+
if (incompleteTasks > 0) {
|
|
129
|
+
if (!options.yes) {
|
|
130
|
+
const proceed = await confirm({
|
|
131
|
+
message: `Warning: ${incompleteTasks} incomplete task(s) found. Continue?`,
|
|
132
|
+
default: false
|
|
133
|
+
});
|
|
134
|
+
if (!proceed) {
|
|
135
|
+
console.log('Archive cancelled.');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
console.log(`Warning: ${incompleteTasks} incomplete task(s) found. Continuing due to --yes flag.`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Handle spec updates unless skipSpecs flag is set
|
|
144
|
+
if (options.skipSpecs) {
|
|
145
|
+
console.log('Skipping spec updates (--skip-specs flag provided).');
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
// Find specs to update
|
|
149
|
+
const specUpdates = await this.findSpecUpdates(changeDir, mainSpecsDir);
|
|
150
|
+
if (specUpdates.length > 0) {
|
|
151
|
+
console.log('\nSpecs to update:');
|
|
152
|
+
for (const update of specUpdates) {
|
|
153
|
+
const status = update.exists ? 'update' : 'create';
|
|
154
|
+
const capability = path.basename(path.dirname(update.target));
|
|
155
|
+
console.log(` ${capability}: ${status}`);
|
|
156
|
+
}
|
|
157
|
+
let shouldUpdateSpecs = true;
|
|
158
|
+
if (!options.yes) {
|
|
159
|
+
shouldUpdateSpecs = await confirm({
|
|
160
|
+
message: 'Proceed with spec updates?',
|
|
161
|
+
default: true
|
|
162
|
+
});
|
|
163
|
+
if (!shouldUpdateSpecs) {
|
|
164
|
+
console.log('Skipping spec updates. Proceeding with archive.');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (shouldUpdateSpecs) {
|
|
168
|
+
// Prepare all updates first (validation pass, no writes)
|
|
169
|
+
const prepared = [];
|
|
170
|
+
try {
|
|
171
|
+
for (const update of specUpdates) {
|
|
172
|
+
const built = await this.buildUpdatedSpec(update, changeName);
|
|
173
|
+
prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
console.log(String(err.message || err));
|
|
178
|
+
console.log('Aborted. No files were changed.');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// All validations passed; pre-validate rebuilt full spec and then write files and display counts
|
|
182
|
+
let totals = { added: 0, modified: 0, removed: 0, renamed: 0 };
|
|
183
|
+
for (const p of prepared) {
|
|
184
|
+
const specName = path.basename(path.dirname(p.update.target));
|
|
185
|
+
if (!options.noValidate) {
|
|
186
|
+
const report = await new Validator().validateSpecContent(specName, p.rebuilt);
|
|
187
|
+
if (!report.valid) {
|
|
188
|
+
console.log(chalk.red(`\nValidation errors in rebuilt spec for ${specName} (will not write changes):`));
|
|
189
|
+
for (const issue of report.issues) {
|
|
190
|
+
if (issue.level === 'ERROR')
|
|
191
|
+
console.log(chalk.red(` ✗ ${issue.message}`));
|
|
192
|
+
else if (issue.level === 'WARNING')
|
|
193
|
+
console.log(chalk.yellow(` ⚠ ${issue.message}`));
|
|
194
|
+
}
|
|
195
|
+
console.log('Aborted. No files were changed.');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
await this.writeUpdatedSpec(p.update, p.rebuilt, p.counts);
|
|
200
|
+
totals.added += p.counts.added;
|
|
201
|
+
totals.modified += p.counts.modified;
|
|
202
|
+
totals.removed += p.counts.removed;
|
|
203
|
+
totals.renamed += p.counts.renamed;
|
|
204
|
+
}
|
|
205
|
+
console.log(`Totals: + ${totals.added}, ~ ${totals.modified}, - ${totals.removed}, → ${totals.renamed}`);
|
|
206
|
+
console.log('Specs updated successfully.');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Create archive directory with date prefix
|
|
211
|
+
const archiveName = `${this.getArchiveDate()}-${changeName}`;
|
|
212
|
+
const archivePath = path.join(archiveDir, archiveName);
|
|
213
|
+
// Check if archive already exists
|
|
214
|
+
try {
|
|
215
|
+
await fs.access(archivePath);
|
|
216
|
+
throw new Error(`Archive '${archiveName}' already exists.`);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (error.code !== 'ENOENT') {
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Create archive directory if needed
|
|
224
|
+
await fs.mkdir(archiveDir, { recursive: true });
|
|
225
|
+
// Move change to archive
|
|
226
|
+
await fs.rename(changeDir, archivePath);
|
|
227
|
+
console.log(`Change '${changeName}' archived as '${archiveName}'.`);
|
|
228
|
+
}
|
|
229
|
+
async selectChange(changesDir) {
|
|
230
|
+
// Get all directories in changes (excluding archive)
|
|
231
|
+
const entries = await fs.readdir(changesDir, { withFileTypes: true });
|
|
232
|
+
const changeDirs = entries
|
|
233
|
+
.filter(entry => entry.isDirectory() && entry.name !== 'archive')
|
|
234
|
+
.map(entry => entry.name)
|
|
235
|
+
.sort();
|
|
236
|
+
if (changeDirs.length === 0) {
|
|
237
|
+
console.log('No active changes found.');
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
// Build choices with progress inline to avoid duplicate lists
|
|
241
|
+
let choices = changeDirs.map(name => ({ name, value: name }));
|
|
242
|
+
try {
|
|
243
|
+
const progressList = [];
|
|
244
|
+
for (const id of changeDirs) {
|
|
245
|
+
const progress = await getTaskProgressForChange(changesDir, id);
|
|
246
|
+
const status = formatTaskStatus(progress);
|
|
247
|
+
progressList.push({ id, status });
|
|
248
|
+
}
|
|
249
|
+
const nameWidth = Math.max(...progressList.map(p => p.id.length));
|
|
250
|
+
choices = progressList.map(p => ({
|
|
251
|
+
name: `${p.id.padEnd(nameWidth)} ${p.status}`,
|
|
252
|
+
value: p.id
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// If anything fails, fall back to simple names
|
|
257
|
+
choices = changeDirs.map(name => ({ name, value: name }));
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
const answer = await select({
|
|
261
|
+
message: 'Select a change to archive',
|
|
262
|
+
choices
|
|
263
|
+
});
|
|
264
|
+
return answer;
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
// User cancelled (Ctrl+C)
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Deprecated: replaced by shared task-progress utilities
|
|
272
|
+
async checkIncompleteTasks(_tasksPath) {
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
async findSpecUpdates(changeDir, mainSpecsDir) {
|
|
276
|
+
const updates = [];
|
|
277
|
+
const changeSpecsDir = path.join(changeDir, 'specs');
|
|
278
|
+
try {
|
|
279
|
+
const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true });
|
|
280
|
+
for (const entry of entries) {
|
|
281
|
+
if (entry.isDirectory()) {
|
|
282
|
+
const specFile = path.join(changeSpecsDir, entry.name, 'spec.md');
|
|
283
|
+
const targetFile = path.join(mainSpecsDir, entry.name, 'spec.md');
|
|
284
|
+
try {
|
|
285
|
+
await fs.access(specFile);
|
|
286
|
+
// Check if target exists
|
|
287
|
+
let exists = false;
|
|
288
|
+
try {
|
|
289
|
+
await fs.access(targetFile);
|
|
290
|
+
exists = true;
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
exists = false;
|
|
294
|
+
}
|
|
295
|
+
updates.push({
|
|
296
|
+
source: specFile,
|
|
297
|
+
target: targetFile,
|
|
298
|
+
exists
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
// Source spec doesn't exist, skip
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// No specs directory in change
|
|
309
|
+
}
|
|
310
|
+
return updates;
|
|
311
|
+
}
|
|
312
|
+
async buildUpdatedSpec(update, changeName) {
|
|
313
|
+
// Read change spec content (delta-format expected)
|
|
314
|
+
const changeContent = await fs.readFile(update.source, 'utf-8');
|
|
315
|
+
// Parse deltas from the change spec file
|
|
316
|
+
const plan = parseDeltaSpec(changeContent);
|
|
317
|
+
const specName = path.basename(path.dirname(update.target));
|
|
318
|
+
// Pre-validate duplicates within sections
|
|
319
|
+
const addedNames = new Set();
|
|
320
|
+
for (const add of plan.added) {
|
|
321
|
+
const name = normalizeRequirementName(add.name);
|
|
322
|
+
if (addedNames.has(name)) {
|
|
323
|
+
throw new Error(`${specName} validation failed - duplicate requirement in ADDED for header "### Requirement: ${add.name}"`);
|
|
324
|
+
}
|
|
325
|
+
addedNames.add(name);
|
|
326
|
+
}
|
|
327
|
+
const modifiedNames = new Set();
|
|
328
|
+
for (const mod of plan.modified) {
|
|
329
|
+
const name = normalizeRequirementName(mod.name);
|
|
330
|
+
if (modifiedNames.has(name)) {
|
|
331
|
+
throw new Error(`${specName} validation failed - duplicate requirement in MODIFIED for header "### Requirement: ${mod.name}"`);
|
|
332
|
+
}
|
|
333
|
+
modifiedNames.add(name);
|
|
334
|
+
}
|
|
335
|
+
const removedNamesSet = new Set();
|
|
336
|
+
for (const rem of plan.removed) {
|
|
337
|
+
const name = normalizeRequirementName(rem);
|
|
338
|
+
if (removedNamesSet.has(name)) {
|
|
339
|
+
throw new Error(`${specName} validation failed - duplicate requirement in REMOVED for header "### Requirement: ${rem}"`);
|
|
340
|
+
}
|
|
341
|
+
removedNamesSet.add(name);
|
|
342
|
+
}
|
|
343
|
+
const renamedFromSet = new Set();
|
|
344
|
+
const renamedToSet = new Set();
|
|
345
|
+
for (const { from, to } of plan.renamed) {
|
|
346
|
+
const fromNorm = normalizeRequirementName(from);
|
|
347
|
+
const toNorm = normalizeRequirementName(to);
|
|
348
|
+
if (renamedFromSet.has(fromNorm)) {
|
|
349
|
+
throw new Error(`${specName} validation failed - duplicate FROM in RENAMED for header "### Requirement: ${from}"`);
|
|
350
|
+
}
|
|
351
|
+
if (renamedToSet.has(toNorm)) {
|
|
352
|
+
throw new Error(`${specName} validation failed - duplicate TO in RENAMED for header "### Requirement: ${to}"`);
|
|
353
|
+
}
|
|
354
|
+
renamedFromSet.add(fromNorm);
|
|
355
|
+
renamedToSet.add(toNorm);
|
|
356
|
+
}
|
|
357
|
+
// Pre-validate cross-section conflicts
|
|
358
|
+
const conflicts = [];
|
|
359
|
+
for (const n of modifiedNames) {
|
|
360
|
+
if (removedNamesSet.has(n))
|
|
361
|
+
conflicts.push({ name: n, a: 'MODIFIED', b: 'REMOVED' });
|
|
362
|
+
if (addedNames.has(n))
|
|
363
|
+
conflicts.push({ name: n, a: 'MODIFIED', b: 'ADDED' });
|
|
364
|
+
}
|
|
365
|
+
for (const n of addedNames) {
|
|
366
|
+
if (removedNamesSet.has(n))
|
|
367
|
+
conflicts.push({ name: n, a: 'ADDED', b: 'REMOVED' });
|
|
368
|
+
}
|
|
369
|
+
// Renamed interplay: MODIFIED must reference the NEW header, not FROM
|
|
370
|
+
for (const { from, to } of plan.renamed) {
|
|
371
|
+
const fromNorm = normalizeRequirementName(from);
|
|
372
|
+
const toNorm = normalizeRequirementName(to);
|
|
373
|
+
if (modifiedNames.has(fromNorm)) {
|
|
374
|
+
throw new Error(`${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header "### Requirement: ${to}"`);
|
|
375
|
+
}
|
|
376
|
+
// Detect ADDED colliding with a RENAMED TO
|
|
377
|
+
if (addedNames.has(toNorm)) {
|
|
378
|
+
throw new Error(`${specName} validation failed - RENAMED TO header collides with ADDED for "### Requirement: ${to}"`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (conflicts.length > 0) {
|
|
382
|
+
const c = conflicts[0];
|
|
383
|
+
throw new Error(`${specName} validation failed - requirement present in multiple sections (${c.a} and ${c.b}) for header "### Requirement: ${c.name}"`);
|
|
384
|
+
}
|
|
385
|
+
const hasAnyDelta = (plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length) > 0;
|
|
386
|
+
if (!hasAnyDelta) {
|
|
387
|
+
throw new Error(`Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` +
|
|
388
|
+
`Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.`);
|
|
389
|
+
}
|
|
390
|
+
// Load or create base target content
|
|
391
|
+
let targetContent;
|
|
392
|
+
try {
|
|
393
|
+
targetContent = await fs.readFile(update.target, 'utf-8');
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
// Target spec does not exist; only ADDED operations are permitted
|
|
397
|
+
if (plan.modified.length > 0 || plan.removed.length > 0 || plan.renamed.length > 0) {
|
|
398
|
+
throw new Error(`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs.`);
|
|
399
|
+
}
|
|
400
|
+
targetContent = this.buildSpecSkeleton(specName, changeName);
|
|
401
|
+
}
|
|
402
|
+
// Extract requirements section and build name->block map
|
|
403
|
+
const parts = extractRequirementsSection(targetContent);
|
|
404
|
+
const nameToBlock = new Map();
|
|
405
|
+
for (const block of parts.bodyBlocks) {
|
|
406
|
+
nameToBlock.set(normalizeRequirementName(block.name), block);
|
|
407
|
+
}
|
|
408
|
+
// Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED
|
|
409
|
+
// RENAMED
|
|
410
|
+
for (const r of plan.renamed) {
|
|
411
|
+
const from = normalizeRequirementName(r.from);
|
|
412
|
+
const to = normalizeRequirementName(r.to);
|
|
413
|
+
if (!nameToBlock.has(from)) {
|
|
414
|
+
throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found`);
|
|
415
|
+
}
|
|
416
|
+
if (nameToBlock.has(to)) {
|
|
417
|
+
throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists`);
|
|
418
|
+
}
|
|
419
|
+
const block = nameToBlock.get(from);
|
|
420
|
+
const newHeader = `### Requirement: ${to}`;
|
|
421
|
+
const rawLines = block.raw.split('\n');
|
|
422
|
+
rawLines[0] = newHeader;
|
|
423
|
+
const renamedBlock = {
|
|
424
|
+
headerLine: newHeader,
|
|
425
|
+
name: to,
|
|
426
|
+
raw: rawLines.join('\n'),
|
|
427
|
+
};
|
|
428
|
+
nameToBlock.delete(from);
|
|
429
|
+
nameToBlock.set(to, renamedBlock);
|
|
430
|
+
}
|
|
431
|
+
// REMOVED
|
|
432
|
+
for (const name of plan.removed) {
|
|
433
|
+
const key = normalizeRequirementName(name);
|
|
434
|
+
if (!nameToBlock.has(key)) {
|
|
435
|
+
throw new Error(`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`);
|
|
436
|
+
}
|
|
437
|
+
nameToBlock.delete(key);
|
|
438
|
+
}
|
|
439
|
+
// MODIFIED
|
|
440
|
+
for (const mod of plan.modified) {
|
|
441
|
+
const key = normalizeRequirementName(mod.name);
|
|
442
|
+
if (!nameToBlock.has(key)) {
|
|
443
|
+
throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`);
|
|
444
|
+
}
|
|
445
|
+
// Replace block with provided raw (ensure header line matches key)
|
|
446
|
+
const modHeaderMatch = mod.raw.split('\n')[0].match(/^###\s*Requirement:\s*(.+)\s*$/);
|
|
447
|
+
if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) {
|
|
448
|
+
throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`);
|
|
449
|
+
}
|
|
450
|
+
nameToBlock.set(key, mod);
|
|
451
|
+
}
|
|
452
|
+
// ADDED
|
|
453
|
+
for (const add of plan.added) {
|
|
454
|
+
const key = normalizeRequirementName(add.name);
|
|
455
|
+
if (nameToBlock.has(key)) {
|
|
456
|
+
throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`);
|
|
457
|
+
}
|
|
458
|
+
nameToBlock.set(key, add);
|
|
459
|
+
}
|
|
460
|
+
// Duplicates within resulting map are implicitly prevented by key uniqueness.
|
|
461
|
+
// Recompose requirements section preserving original ordering where possible
|
|
462
|
+
const keptOrder = [];
|
|
463
|
+
const seen = new Set();
|
|
464
|
+
for (const block of parts.bodyBlocks) {
|
|
465
|
+
const key = normalizeRequirementName(block.name);
|
|
466
|
+
const replacement = nameToBlock.get(key);
|
|
467
|
+
if (replacement) {
|
|
468
|
+
keptOrder.push(replacement);
|
|
469
|
+
seen.add(key);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// Append any newly added that were not in original order
|
|
473
|
+
for (const [key, block] of nameToBlock.entries()) {
|
|
474
|
+
if (!seen.has(key)) {
|
|
475
|
+
keptOrder.push(block);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const reqBody = [
|
|
479
|
+
parts.preamble && parts.preamble.trim() ? parts.preamble.trimEnd() : ''
|
|
480
|
+
]
|
|
481
|
+
.filter(Boolean)
|
|
482
|
+
.concat(keptOrder.map(b => b.raw))
|
|
483
|
+
.join('\n\n')
|
|
484
|
+
.trimEnd();
|
|
485
|
+
const rebuilt = [
|
|
486
|
+
parts.before.trimEnd(),
|
|
487
|
+
parts.headerLine,
|
|
488
|
+
reqBody,
|
|
489
|
+
parts.after
|
|
490
|
+
]
|
|
491
|
+
.filter((s, idx) => !(idx === 0 && s === ''))
|
|
492
|
+
.join('\n')
|
|
493
|
+
.replace(/\n{3,}/g, '\n\n');
|
|
494
|
+
return {
|
|
495
|
+
rebuilt,
|
|
496
|
+
counts: {
|
|
497
|
+
added: plan.added.length,
|
|
498
|
+
modified: plan.modified.length,
|
|
499
|
+
removed: plan.removed.length,
|
|
500
|
+
renamed: plan.renamed.length,
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
async writeUpdatedSpec(update, rebuilt, counts) {
|
|
505
|
+
// Create target directory if needed
|
|
506
|
+
const targetDir = path.dirname(update.target);
|
|
507
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
508
|
+
await fs.writeFile(update.target, rebuilt);
|
|
509
|
+
const specName = path.basename(path.dirname(update.target));
|
|
510
|
+
console.log(`Applying changes to openspec/specs/${specName}/spec.md:`);
|
|
511
|
+
if (counts.added)
|
|
512
|
+
console.log(` + ${counts.added} added`);
|
|
513
|
+
if (counts.modified)
|
|
514
|
+
console.log(` ~ ${counts.modified} modified`);
|
|
515
|
+
if (counts.removed)
|
|
516
|
+
console.log(` - ${counts.removed} removed`);
|
|
517
|
+
if (counts.renamed)
|
|
518
|
+
console.log(` → ${counts.renamed} renamed`);
|
|
519
|
+
}
|
|
520
|
+
buildSpecSkeleton(specFolderName, changeName) {
|
|
521
|
+
const titleBase = specFolderName;
|
|
522
|
+
return `# ${titleBase} Specification\n\n## Purpose\nTBD - created by archiving change ${changeName}. Update Purpose after archive.\n\n## Requirements\n`;
|
|
523
|
+
}
|
|
524
|
+
getArchiveDate() {
|
|
525
|
+
// Returns date in YYYY-MM-DD format
|
|
526
|
+
return new Date().toISOString().split('T')[0];
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
//# sourceMappingURL=archive.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const OPENSPEC_DIR_NAME = "openspec";
|
|
2
|
+
export interface OpenSpecConfig {
|
|
3
|
+
aiTools: string[];
|
|
4
|
+
}
|
|
5
|
+
export declare const OPENSPEC_MARKERS: {
|
|
6
|
+
start: string;
|
|
7
|
+
end: string;
|
|
8
|
+
};
|
|
9
|
+
export declare const AI_TOOLS: {
|
|
10
|
+
name: string;
|
|
11
|
+
value: string;
|
|
12
|
+
available: boolean;
|
|
13
|
+
}[];
|
|
14
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const OPENSPEC_DIR_NAME = 'openspec';
|
|
2
|
+
export const OPENSPEC_MARKERS = {
|
|
3
|
+
start: '<!-- OPENSPEC:START -->',
|
|
4
|
+
end: '<!-- OPENSPEC:END -->'
|
|
5
|
+
};
|
|
6
|
+
export const AI_TOOLS = [
|
|
7
|
+
{ name: 'Claude Code', value: 'claude', available: true },
|
|
8
|
+
{ name: 'Cursor', value: 'cursor', available: false },
|
|
9
|
+
{ name: 'Aider', value: 'aider', available: false },
|
|
10
|
+
{ name: 'Continue', value: 'continue', available: false }
|
|
11
|
+
];
|
|
12
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ToolConfigurator } from './base.js';
|
|
2
|
+
export declare class ClaudeConfigurator implements ToolConfigurator {
|
|
3
|
+
name: string;
|
|
4
|
+
configFileName: string;
|
|
5
|
+
isAvailable: boolean;
|
|
6
|
+
configure(projectPath: string, openspecDir: string): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=claude.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
3
|
+
import { TemplateManager } from '../templates/index.js';
|
|
4
|
+
import { OPENSPEC_MARKERS } from '../config.js';
|
|
5
|
+
export class ClaudeConfigurator {
|
|
6
|
+
name = 'Claude Code';
|
|
7
|
+
configFileName = 'CLAUDE.md';
|
|
8
|
+
isAvailable = true;
|
|
9
|
+
async configure(projectPath, openspecDir) {
|
|
10
|
+
const filePath = path.join(projectPath, this.configFileName);
|
|
11
|
+
const content = TemplateManager.getClaudeTemplate();
|
|
12
|
+
await FileSystemUtils.updateFileWithMarkers(filePath, content, OPENSPEC_MARKERS.start, OPENSPEC_MARKERS.end);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=claude.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ToolConfigurator } from './base.js';
|
|
2
|
+
export declare class ToolRegistry {
|
|
3
|
+
private static tools;
|
|
4
|
+
static register(tool: ToolConfigurator): void;
|
|
5
|
+
static get(toolId: string): ToolConfigurator | undefined;
|
|
6
|
+
static getAll(): ToolConfigurator[];
|
|
7
|
+
static getAvailable(): ToolConfigurator[];
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=registry.d.ts.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ClaudeConfigurator } from './claude.js';
|
|
2
|
+
export class ToolRegistry {
|
|
3
|
+
static tools = new Map();
|
|
4
|
+
static {
|
|
5
|
+
const claudeConfigurator = new ClaudeConfigurator();
|
|
6
|
+
// Register with the ID that matches the checkbox value
|
|
7
|
+
this.tools.set('claude', claudeConfigurator);
|
|
8
|
+
}
|
|
9
|
+
static register(tool) {
|
|
10
|
+
this.tools.set(tool.name.toLowerCase().replace(/\s+/g, '-'), tool);
|
|
11
|
+
}
|
|
12
|
+
static get(toolId) {
|
|
13
|
+
return this.tools.get(toolId);
|
|
14
|
+
}
|
|
15
|
+
static getAll() {
|
|
16
|
+
return Array.from(this.tools.values());
|
|
17
|
+
}
|
|
18
|
+
static getAvailable() {
|
|
19
|
+
return this.getAll().filter(tool => tool.isAvailable);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=registry.js.map
|