@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.
Files changed (77) hide show
  1. package/README.md +119 -0
  2. package/bin/openspec.js +3 -0
  3. package/dist/cli/index.d.ts +2 -0
  4. package/dist/cli/index.js +240 -0
  5. package/dist/commands/change.d.ts +35 -0
  6. package/dist/commands/change.js +276 -0
  7. package/dist/commands/show.d.ts +14 -0
  8. package/dist/commands/show.js +131 -0
  9. package/dist/commands/spec.d.ts +15 -0
  10. package/dist/commands/spec.js +224 -0
  11. package/dist/commands/validate.d.ts +23 -0
  12. package/dist/commands/validate.js +275 -0
  13. package/dist/core/archive.d.ts +15 -0
  14. package/dist/core/archive.js +529 -0
  15. package/dist/core/config.d.ts +14 -0
  16. package/dist/core/config.js +12 -0
  17. package/dist/core/configurators/base.d.ts +7 -0
  18. package/dist/core/configurators/base.js +2 -0
  19. package/dist/core/configurators/claude.d.ts +8 -0
  20. package/dist/core/configurators/claude.js +15 -0
  21. package/dist/core/configurators/registry.d.ts +9 -0
  22. package/dist/core/configurators/registry.js +22 -0
  23. package/dist/core/converters/json-converter.d.ts +6 -0
  24. package/dist/core/converters/json-converter.js +48 -0
  25. package/dist/core/diff.d.ts +11 -0
  26. package/dist/core/diff.js +193 -0
  27. package/dist/core/index.d.ts +2 -0
  28. package/dist/core/index.js +2 -0
  29. package/dist/core/init.d.ts +10 -0
  30. package/dist/core/init.js +109 -0
  31. package/dist/core/list.d.ts +4 -0
  32. package/dist/core/list.js +89 -0
  33. package/dist/core/parsers/change-parser.d.ts +13 -0
  34. package/dist/core/parsers/change-parser.js +192 -0
  35. package/dist/core/parsers/markdown-parser.d.ts +21 -0
  36. package/dist/core/parsers/markdown-parser.js +183 -0
  37. package/dist/core/parsers/requirement-blocks.d.ts +31 -0
  38. package/dist/core/parsers/requirement-blocks.js +173 -0
  39. package/dist/core/schemas/base.schema.d.ts +13 -0
  40. package/dist/core/schemas/base.schema.js +13 -0
  41. package/dist/core/schemas/change.schema.d.ts +73 -0
  42. package/dist/core/schemas/change.schema.js +31 -0
  43. package/dist/core/schemas/index.d.ts +4 -0
  44. package/dist/core/schemas/index.js +4 -0
  45. package/dist/core/schemas/spec.schema.d.ts +18 -0
  46. package/dist/core/schemas/spec.schema.js +15 -0
  47. package/dist/core/templates/claude-template.d.ts +2 -0
  48. package/dist/core/templates/claude-template.js +96 -0
  49. package/dist/core/templates/index.d.ts +11 -0
  50. package/dist/core/templates/index.js +21 -0
  51. package/dist/core/templates/project-template.d.ts +8 -0
  52. package/dist/core/templates/project-template.js +32 -0
  53. package/dist/core/templates/readme-template.d.ts +2 -0
  54. package/dist/core/templates/readme-template.js +519 -0
  55. package/dist/core/update.d.ts +4 -0
  56. package/dist/core/update.js +47 -0
  57. package/dist/core/validation/constants.d.ts +34 -0
  58. package/dist/core/validation/constants.js +40 -0
  59. package/dist/core/validation/types.d.ts +18 -0
  60. package/dist/core/validation/types.js +2 -0
  61. package/dist/core/validation/validator.d.ts +32 -0
  62. package/dist/core/validation/validator.js +355 -0
  63. package/dist/index.d.ts +3 -0
  64. package/dist/index.js +3 -0
  65. package/dist/utils/file-system.d.ts +10 -0
  66. package/dist/utils/file-system.js +83 -0
  67. package/dist/utils/index.d.ts +2 -0
  68. package/dist/utils/index.js +2 -0
  69. package/dist/utils/interactive.d.ts +2 -0
  70. package/dist/utils/interactive.js +8 -0
  71. package/dist/utils/item-discovery.d.ts +3 -0
  72. package/dist/utils/item-discovery.js +49 -0
  73. package/dist/utils/match.d.ts +3 -0
  74. package/dist/utils/match.js +22 -0
  75. package/dist/utils/task-progress.d.ts +8 -0
  76. package/dist/utils/task-progress.js +36 -0
  77. package/package.json +68 -0
@@ -0,0 +1,32 @@
1
+ import { ValidationReport } from './types.js';
2
+ export declare class Validator {
3
+ private strictMode;
4
+ constructor(strictMode?: boolean);
5
+ validateSpec(filePath: string): Promise<ValidationReport>;
6
+ /**
7
+ * Validate spec content from a string (used for pre-write validation of rebuilt specs)
8
+ */
9
+ validateSpecContent(specName: string, content: string): Promise<ValidationReport>;
10
+ validateChange(filePath: string): Promise<ValidationReport>;
11
+ /**
12
+ * Validate delta-formatted spec files under a change directory.
13
+ * Enforces:
14
+ * - At least one delta across all files
15
+ * - ADDED/MODIFIED: each requirement has SHALL/MUST and at least one scenario
16
+ * - REMOVED: names only; no scenario/description required
17
+ * - RENAMED: pairs well-formed
18
+ * - No duplicates within sections; no cross-section conflicts per spec
19
+ */
20
+ validateChangeDeltaSpecs(changeDir: string): Promise<ValidationReport>;
21
+ private convertZodErrors;
22
+ private applySpecRules;
23
+ private applyChangeRules;
24
+ private enrichTopLevelError;
25
+ private extractNameFromPath;
26
+ private createReport;
27
+ isValid(report: ValidationReport): boolean;
28
+ private extractRequirementText;
29
+ private containsShallOrMust;
30
+ private countScenarios;
31
+ }
32
+ //# sourceMappingURL=validator.d.ts.map
@@ -0,0 +1,355 @@
1
+ import { readFileSync, promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import { SpecSchema, ChangeSchema } from '../schemas/index.js';
4
+ import { MarkdownParser } from '../parsers/markdown-parser.js';
5
+ import { ChangeParser } from '../parsers/change-parser.js';
6
+ import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
7
+ import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js';
8
+ export class Validator {
9
+ strictMode;
10
+ constructor(strictMode = false) {
11
+ this.strictMode = strictMode;
12
+ }
13
+ async validateSpec(filePath) {
14
+ const issues = [];
15
+ const specName = this.extractNameFromPath(filePath);
16
+ try {
17
+ const content = readFileSync(filePath, 'utf-8');
18
+ const parser = new MarkdownParser(content);
19
+ const spec = parser.parseSpec(specName);
20
+ const result = SpecSchema.safeParse(spec);
21
+ if (!result.success) {
22
+ issues.push(...this.convertZodErrors(result.error));
23
+ }
24
+ issues.push(...this.applySpecRules(spec, content));
25
+ }
26
+ catch (error) {
27
+ const baseMessage = error instanceof Error ? error.message : 'Unknown error';
28
+ const enriched = this.enrichTopLevelError(specName, baseMessage);
29
+ issues.push({
30
+ level: 'ERROR',
31
+ path: 'file',
32
+ message: enriched,
33
+ });
34
+ }
35
+ return this.createReport(issues);
36
+ }
37
+ /**
38
+ * Validate spec content from a string (used for pre-write validation of rebuilt specs)
39
+ */
40
+ async validateSpecContent(specName, content) {
41
+ const issues = [];
42
+ try {
43
+ const parser = new MarkdownParser(content);
44
+ const spec = parser.parseSpec(specName);
45
+ const result = SpecSchema.safeParse(spec);
46
+ if (!result.success) {
47
+ issues.push(...this.convertZodErrors(result.error));
48
+ }
49
+ issues.push(...this.applySpecRules(spec, content));
50
+ }
51
+ catch (error) {
52
+ const baseMessage = error instanceof Error ? error.message : 'Unknown error';
53
+ const enriched = this.enrichTopLevelError(specName, baseMessage);
54
+ issues.push({ level: 'ERROR', path: 'file', message: enriched });
55
+ }
56
+ return this.createReport(issues);
57
+ }
58
+ async validateChange(filePath) {
59
+ const issues = [];
60
+ const changeName = this.extractNameFromPath(filePath);
61
+ try {
62
+ const content = readFileSync(filePath, 'utf-8');
63
+ const changeDir = path.dirname(filePath);
64
+ const parser = new ChangeParser(content, changeDir);
65
+ const change = await parser.parseChangeWithDeltas(changeName);
66
+ const result = ChangeSchema.safeParse(change);
67
+ if (!result.success) {
68
+ issues.push(...this.convertZodErrors(result.error));
69
+ }
70
+ issues.push(...this.applyChangeRules(change, content));
71
+ }
72
+ catch (error) {
73
+ const baseMessage = error instanceof Error ? error.message : 'Unknown error';
74
+ const enriched = this.enrichTopLevelError(changeName, baseMessage);
75
+ issues.push({
76
+ level: 'ERROR',
77
+ path: 'file',
78
+ message: enriched,
79
+ });
80
+ }
81
+ return this.createReport(issues);
82
+ }
83
+ /**
84
+ * Validate delta-formatted spec files under a change directory.
85
+ * Enforces:
86
+ * - At least one delta across all files
87
+ * - ADDED/MODIFIED: each requirement has SHALL/MUST and at least one scenario
88
+ * - REMOVED: names only; no scenario/description required
89
+ * - RENAMED: pairs well-formed
90
+ * - No duplicates within sections; no cross-section conflicts per spec
91
+ */
92
+ async validateChangeDeltaSpecs(changeDir) {
93
+ const issues = [];
94
+ const specsDir = path.join(changeDir, 'specs');
95
+ let totalDeltas = 0;
96
+ try {
97
+ const entries = await fs.readdir(specsDir, { withFileTypes: true });
98
+ for (const entry of entries) {
99
+ if (!entry.isDirectory())
100
+ continue;
101
+ const specName = entry.name;
102
+ const specFile = path.join(specsDir, specName, 'spec.md');
103
+ let content;
104
+ try {
105
+ content = await fs.readFile(specFile, 'utf-8');
106
+ }
107
+ catch {
108
+ continue;
109
+ }
110
+ const plan = parseDeltaSpec(content);
111
+ const entryPath = `${specName}/spec.md`;
112
+ const addedNames = new Set();
113
+ const modifiedNames = new Set();
114
+ const removedNames = new Set();
115
+ const renamedFrom = new Set();
116
+ const renamedTo = new Set();
117
+ // Validate ADDED
118
+ for (const block of plan.added) {
119
+ const key = normalizeRequirementName(block.name);
120
+ totalDeltas++;
121
+ if (addedNames.has(key)) {
122
+ issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate requirement in ADDED: "${block.name}"` });
123
+ }
124
+ else {
125
+ addedNames.add(key);
126
+ }
127
+ const requirementText = this.extractRequirementText(block.raw);
128
+ if (!requirementText) {
129
+ issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" is missing requirement text` });
130
+ }
131
+ else if (!this.containsShallOrMust(requirementText)) {
132
+ issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must contain SHALL or MUST` });
133
+ }
134
+ const scenarioCount = this.countScenarios(block.raw);
135
+ if (scenarioCount < 1) {
136
+ issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must include at least one scenario` });
137
+ }
138
+ }
139
+ // Validate MODIFIED
140
+ for (const block of plan.modified) {
141
+ const key = normalizeRequirementName(block.name);
142
+ totalDeltas++;
143
+ if (modifiedNames.has(key)) {
144
+ issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate requirement in MODIFIED: "${block.name}"` });
145
+ }
146
+ else {
147
+ modifiedNames.add(key);
148
+ }
149
+ const requirementText = this.extractRequirementText(block.raw);
150
+ if (!requirementText) {
151
+ issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" is missing requirement text` });
152
+ }
153
+ else if (!this.containsShallOrMust(requirementText)) {
154
+ issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must contain SHALL or MUST` });
155
+ }
156
+ const scenarioCount = this.countScenarios(block.raw);
157
+ if (scenarioCount < 1) {
158
+ issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must include at least one scenario` });
159
+ }
160
+ }
161
+ // Validate REMOVED (names only)
162
+ for (const name of plan.removed) {
163
+ const key = normalizeRequirementName(name);
164
+ totalDeltas++;
165
+ if (removedNames.has(key)) {
166
+ issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate requirement in REMOVED: "${name}"` });
167
+ }
168
+ else {
169
+ removedNames.add(key);
170
+ }
171
+ }
172
+ // Validate RENAMED pairs
173
+ for (const { from, to } of plan.renamed) {
174
+ const fromKey = normalizeRequirementName(from);
175
+ const toKey = normalizeRequirementName(to);
176
+ totalDeltas++;
177
+ if (renamedFrom.has(fromKey)) {
178
+ issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate FROM in RENAMED: "${from}"` });
179
+ }
180
+ else {
181
+ renamedFrom.add(fromKey);
182
+ }
183
+ if (renamedTo.has(toKey)) {
184
+ issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate TO in RENAMED: "${to}"` });
185
+ }
186
+ else {
187
+ renamedTo.add(toKey);
188
+ }
189
+ }
190
+ // Cross-section conflicts (within the same spec file)
191
+ for (const n of modifiedNames) {
192
+ if (removedNames.has(n)) {
193
+ issues.push({ level: 'ERROR', path: entryPath, message: `Requirement present in both MODIFIED and REMOVED: "${n}"` });
194
+ }
195
+ if (addedNames.has(n)) {
196
+ issues.push({ level: 'ERROR', path: entryPath, message: `Requirement present in both MODIFIED and ADDED: "${n}"` });
197
+ }
198
+ }
199
+ for (const n of addedNames) {
200
+ if (removedNames.has(n)) {
201
+ issues.push({ level: 'ERROR', path: entryPath, message: `Requirement present in both ADDED and REMOVED: "${n}"` });
202
+ }
203
+ }
204
+ for (const { from, to } of plan.renamed) {
205
+ const fromKey = normalizeRequirementName(from);
206
+ const toKey = normalizeRequirementName(to);
207
+ if (modifiedNames.has(fromKey)) {
208
+ issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED references old name from RENAMED. Use new header for "${to}"` });
209
+ }
210
+ if (addedNames.has(toKey)) {
211
+ issues.push({ level: 'ERROR', path: entryPath, message: `RENAMED TO collides with ADDED for "${to}"` });
212
+ }
213
+ }
214
+ }
215
+ }
216
+ catch {
217
+ // If no specs dir, treat as no deltas
218
+ }
219
+ if (totalDeltas === 0) {
220
+ issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) });
221
+ }
222
+ return this.createReport(issues);
223
+ }
224
+ convertZodErrors(error) {
225
+ return error.issues.map(err => {
226
+ let message = err.message;
227
+ if (message === VALIDATION_MESSAGES.CHANGE_NO_DELTAS) {
228
+ message = `${message}. ${VALIDATION_MESSAGES.GUIDE_NO_DELTAS}`;
229
+ }
230
+ return {
231
+ level: 'ERROR',
232
+ path: err.path.join('.'),
233
+ message,
234
+ };
235
+ });
236
+ }
237
+ applySpecRules(spec, content) {
238
+ const issues = [];
239
+ if (spec.overview.length < MIN_PURPOSE_LENGTH) {
240
+ issues.push({
241
+ level: 'WARNING',
242
+ path: 'overview',
243
+ message: VALIDATION_MESSAGES.PURPOSE_TOO_BRIEF,
244
+ });
245
+ }
246
+ spec.requirements.forEach((req, index) => {
247
+ if (req.text.length > MAX_REQUIREMENT_TEXT_LENGTH) {
248
+ issues.push({
249
+ level: 'INFO',
250
+ path: `requirements[${index}]`,
251
+ message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG,
252
+ });
253
+ }
254
+ if (req.scenarios.length === 0) {
255
+ issues.push({
256
+ level: 'WARNING',
257
+ path: `requirements[${index}].scenarios`,
258
+ message: `${VALIDATION_MESSAGES.REQUIREMENT_NO_SCENARIOS}. ${VALIDATION_MESSAGES.GUIDE_SCENARIO_FORMAT}`,
259
+ });
260
+ }
261
+ });
262
+ return issues;
263
+ }
264
+ applyChangeRules(change, content) {
265
+ const issues = [];
266
+ const MIN_DELTA_DESCRIPTION_LENGTH = 10;
267
+ change.deltas.forEach((delta, index) => {
268
+ if (!delta.description || delta.description.length < MIN_DELTA_DESCRIPTION_LENGTH) {
269
+ issues.push({
270
+ level: 'WARNING',
271
+ path: `deltas[${index}].description`,
272
+ message: VALIDATION_MESSAGES.DELTA_DESCRIPTION_TOO_BRIEF,
273
+ });
274
+ }
275
+ if ((delta.operation === 'ADDED' || delta.operation === 'MODIFIED') &&
276
+ (!delta.requirements || delta.requirements.length === 0)) {
277
+ issues.push({
278
+ level: 'WARNING',
279
+ path: `deltas[${index}].requirements`,
280
+ message: `${delta.operation} ${VALIDATION_MESSAGES.DELTA_MISSING_REQUIREMENTS}`,
281
+ });
282
+ }
283
+ });
284
+ return issues;
285
+ }
286
+ enrichTopLevelError(itemId, baseMessage) {
287
+ const msg = baseMessage.trim();
288
+ if (msg === VALIDATION_MESSAGES.CHANGE_NO_DELTAS) {
289
+ return `${msg}. ${VALIDATION_MESSAGES.GUIDE_NO_DELTAS}`;
290
+ }
291
+ if (msg.includes('Spec must have a Purpose section') || msg.includes('Spec must have a Requirements section')) {
292
+ return `${msg}. ${VALIDATION_MESSAGES.GUIDE_MISSING_SPEC_SECTIONS}`;
293
+ }
294
+ if (msg.includes('Change must have a Why section') || msg.includes('Change must have a What Changes section')) {
295
+ return `${msg}. ${VALIDATION_MESSAGES.GUIDE_MISSING_CHANGE_SECTIONS}`;
296
+ }
297
+ return msg;
298
+ }
299
+ extractNameFromPath(filePath) {
300
+ const parts = filePath.split('/');
301
+ // Look for the directory name after 'specs' or 'changes'
302
+ for (let i = parts.length - 1; i >= 0; i--) {
303
+ if (parts[i] === 'specs' || parts[i] === 'changes') {
304
+ if (i < parts.length - 1) {
305
+ return parts[i + 1];
306
+ }
307
+ }
308
+ }
309
+ // Fallback to filename without extension if not in expected structure
310
+ const fileName = parts[parts.length - 1];
311
+ return fileName.replace('.md', '');
312
+ }
313
+ createReport(issues) {
314
+ const errors = issues.filter(i => i.level === 'ERROR').length;
315
+ const warnings = issues.filter(i => i.level === 'WARNING').length;
316
+ const info = issues.filter(i => i.level === 'INFO').length;
317
+ const valid = this.strictMode
318
+ ? errors === 0 && warnings === 0
319
+ : errors === 0;
320
+ return {
321
+ valid,
322
+ issues,
323
+ summary: {
324
+ errors,
325
+ warnings,
326
+ info,
327
+ },
328
+ };
329
+ }
330
+ isValid(report) {
331
+ return report.valid;
332
+ }
333
+ extractRequirementText(blockRaw) {
334
+ const lines = blockRaw.split('\n');
335
+ // Skip header
336
+ let i = 1;
337
+ const bodyLines = [];
338
+ for (; i < lines.length; i++) {
339
+ const line = lines[i];
340
+ if (/^####\s+/.test(line))
341
+ break; // scenarios start
342
+ bodyLines.push(line);
343
+ }
344
+ const text = bodyLines.join('\n').split('\n').map(l => l.trim()).find(l => l.length > 0);
345
+ return text;
346
+ }
347
+ containsShallOrMust(text) {
348
+ return /\b(SHALL|MUST)\b/.test(text);
349
+ }
350
+ countScenarios(blockRaw) {
351
+ const matches = blockRaw.match(/^####\s+/gm);
352
+ return matches ? matches.length : 0;
353
+ }
354
+ }
355
+ //# sourceMappingURL=validator.js.map
@@ -0,0 +1,3 @@
1
+ export * from './cli/index.js';
2
+ export * from './core/index.js';
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from './cli/index.js';
2
+ export * from './core/index.js';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,10 @@
1
+ export declare class FileSystemUtils {
2
+ static createDirectory(dirPath: string): Promise<void>;
3
+ static fileExists(filePath: string): Promise<boolean>;
4
+ static directoryExists(dirPath: string): Promise<boolean>;
5
+ static writeFile(filePath: string, content: string): Promise<void>;
6
+ static readFile(filePath: string): Promise<string>;
7
+ static updateFileWithMarkers(filePath: string, content: string, startMarker: string, endMarker: string): Promise<void>;
8
+ static ensureWritePermissions(dirPath: string): Promise<boolean>;
9
+ }
10
+ //# sourceMappingURL=file-system.d.ts.map
@@ -0,0 +1,83 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ export class FileSystemUtils {
4
+ static async createDirectory(dirPath) {
5
+ await fs.mkdir(dirPath, { recursive: true });
6
+ }
7
+ static async fileExists(filePath) {
8
+ try {
9
+ await fs.access(filePath);
10
+ return true;
11
+ }
12
+ catch (error) {
13
+ if (error.code !== 'ENOENT') {
14
+ console.debug(`Unable to check if file exists at ${filePath}: ${error.message}`);
15
+ }
16
+ return false;
17
+ }
18
+ }
19
+ static async directoryExists(dirPath) {
20
+ try {
21
+ const stats = await fs.stat(dirPath);
22
+ return stats.isDirectory();
23
+ }
24
+ catch (error) {
25
+ if (error.code !== 'ENOENT') {
26
+ console.debug(`Unable to check if directory exists at ${dirPath}: ${error.message}`);
27
+ }
28
+ return false;
29
+ }
30
+ }
31
+ static async writeFile(filePath, content) {
32
+ const dir = path.dirname(filePath);
33
+ await this.createDirectory(dir);
34
+ await fs.writeFile(filePath, content, 'utf-8');
35
+ }
36
+ static async readFile(filePath) {
37
+ return await fs.readFile(filePath, 'utf-8');
38
+ }
39
+ static async updateFileWithMarkers(filePath, content, startMarker, endMarker) {
40
+ let existingContent = '';
41
+ if (await this.fileExists(filePath)) {
42
+ existingContent = await this.readFile(filePath);
43
+ const startIndex = existingContent.indexOf(startMarker);
44
+ const endIndex = existingContent.indexOf(endMarker);
45
+ if (startIndex !== -1 && endIndex !== -1) {
46
+ const before = existingContent.substring(0, startIndex);
47
+ const after = existingContent.substring(endIndex + endMarker.length);
48
+ existingContent = before + startMarker + '\n' + content + '\n' + endMarker + after;
49
+ }
50
+ else if (startIndex === -1 && endIndex === -1) {
51
+ existingContent = startMarker + '\n' + content + '\n' + endMarker + '\n\n' + existingContent;
52
+ }
53
+ else {
54
+ throw new Error(`Invalid marker state in ${filePath}. Found start: ${startIndex !== -1}, Found end: ${endIndex !== -1}`);
55
+ }
56
+ }
57
+ else {
58
+ existingContent = startMarker + '\n' + content + '\n' + endMarker;
59
+ }
60
+ await this.writeFile(filePath, existingContent);
61
+ }
62
+ static async ensureWritePermissions(dirPath) {
63
+ try {
64
+ // If directory doesn't exist, check parent directory permissions
65
+ if (!await this.directoryExists(dirPath)) {
66
+ const parentDir = path.dirname(dirPath);
67
+ if (!await this.directoryExists(parentDir)) {
68
+ await this.createDirectory(parentDir);
69
+ }
70
+ return await this.ensureWritePermissions(parentDir);
71
+ }
72
+ const testFile = path.join(dirPath, '.openspec-test-' + Date.now());
73
+ await fs.writeFile(testFile, '');
74
+ await fs.unlink(testFile);
75
+ return true;
76
+ }
77
+ catch (error) {
78
+ console.debug(`Insufficient permissions to write to ${dirPath}: ${error.message}`);
79
+ return false;
80
+ }
81
+ }
82
+ }
83
+ //# sourceMappingURL=file-system.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,2 @@
1
+ export declare function isInteractive(noInteractiveFlag?: boolean): boolean;
2
+ //# sourceMappingURL=interactive.d.ts.map
@@ -0,0 +1,8 @@
1
+ export function isInteractive(noInteractiveFlag) {
2
+ if (noInteractiveFlag)
3
+ return false;
4
+ if (process.env.OPEN_SPEC_INTERACTIVE === '0')
5
+ return false;
6
+ return !!process.stdin.isTTY;
7
+ }
8
+ //# sourceMappingURL=interactive.js.map
@@ -0,0 +1,3 @@
1
+ export declare function getActiveChangeIds(root?: string): Promise<string[]>;
2
+ export declare function getSpecIds(root?: string): Promise<string[]>;
3
+ //# sourceMappingURL=item-discovery.d.ts.map
@@ -0,0 +1,49 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ export async function getActiveChangeIds(root = process.cwd()) {
4
+ const changesPath = path.join(root, 'openspec', 'changes');
5
+ try {
6
+ const entries = await fs.readdir(changesPath, { withFileTypes: true });
7
+ const result = [];
8
+ for (const entry of entries) {
9
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'archive')
10
+ continue;
11
+ const proposalPath = path.join(changesPath, entry.name, 'proposal.md');
12
+ try {
13
+ await fs.access(proposalPath);
14
+ result.push(entry.name);
15
+ }
16
+ catch {
17
+ // skip directories without proposal.md
18
+ }
19
+ }
20
+ return result.sort();
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ }
26
+ export async function getSpecIds(root = process.cwd()) {
27
+ const specsPath = path.join(root, 'openspec', 'specs');
28
+ const result = [];
29
+ try {
30
+ const entries = await fs.readdir(specsPath, { withFileTypes: true });
31
+ for (const entry of entries) {
32
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
33
+ continue;
34
+ const specFile = path.join(specsPath, entry.name, 'spec.md');
35
+ try {
36
+ await fs.access(specFile);
37
+ result.push(entry.name);
38
+ }
39
+ catch {
40
+ // ignore
41
+ }
42
+ }
43
+ }
44
+ catch {
45
+ // ignore
46
+ }
47
+ return result.sort();
48
+ }
49
+ //# sourceMappingURL=item-discovery.js.map
@@ -0,0 +1,3 @@
1
+ export declare function nearestMatches(input: string, candidates: string[], max?: number): string[];
2
+ export declare function levenshtein(a: string, b: string): number;
3
+ //# sourceMappingURL=match.d.ts.map
@@ -0,0 +1,22 @@
1
+ export function nearestMatches(input, candidates, max = 5) {
2
+ const scored = candidates.map(candidate => ({ candidate, distance: levenshtein(input, candidate) }));
3
+ scored.sort((a, b) => a.distance - b.distance);
4
+ return scored.slice(0, max).map(s => s.candidate);
5
+ }
6
+ export function levenshtein(a, b) {
7
+ const m = a.length;
8
+ const n = b.length;
9
+ const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
10
+ for (let i = 0; i <= m; i++)
11
+ dp[i][0] = i;
12
+ for (let j = 0; j <= n; j++)
13
+ dp[0][j] = j;
14
+ for (let i = 1; i <= m; i++) {
15
+ for (let j = 1; j <= n; j++) {
16
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
17
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
18
+ }
19
+ }
20
+ return dp[m][n];
21
+ }
22
+ //# sourceMappingURL=match.js.map
@@ -0,0 +1,8 @@
1
+ export interface TaskProgress {
2
+ total: number;
3
+ completed: number;
4
+ }
5
+ export declare function countTasksFromContent(content: string): TaskProgress;
6
+ export declare function getTaskProgressForChange(changesDir: string, changeName: string): Promise<TaskProgress>;
7
+ export declare function formatTaskStatus(progress: TaskProgress): string;
8
+ //# sourceMappingURL=task-progress.d.ts.map
@@ -0,0 +1,36 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i;
4
+ const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i;
5
+ export function countTasksFromContent(content) {
6
+ const lines = content.split('\n');
7
+ let total = 0;
8
+ let completed = 0;
9
+ for (const line of lines) {
10
+ if (line.match(TASK_PATTERN)) {
11
+ total++;
12
+ if (line.match(COMPLETED_TASK_PATTERN)) {
13
+ completed++;
14
+ }
15
+ }
16
+ }
17
+ return { total, completed };
18
+ }
19
+ export async function getTaskProgressForChange(changesDir, changeName) {
20
+ const tasksPath = path.join(changesDir, changeName, 'tasks.md');
21
+ try {
22
+ const content = await fs.readFile(tasksPath, 'utf-8');
23
+ return countTasksFromContent(content);
24
+ }
25
+ catch {
26
+ return { total: 0, completed: 0 };
27
+ }
28
+ }
29
+ export function formatTaskStatus(progress) {
30
+ if (progress.total === 0)
31
+ return 'No tasks';
32
+ if (progress.completed === progress.total)
33
+ return '✓ Complete';
34
+ return `${progress.completed}/${progress.total} tasks`;
35
+ }
36
+ //# sourceMappingURL=task-progress.js.map