@sudocode-ai/integration-openspec 0.1.14
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/id-generator.d.ts +114 -0
- package/dist/id-generator.d.ts.map +1 -0
- package/dist/id-generator.js +165 -0
- package/dist/id-generator.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +692 -0
- package/dist/index.js.map +1 -0
- package/dist/parser/change-parser.d.ts +164 -0
- package/dist/parser/change-parser.d.ts.map +1 -0
- package/dist/parser/change-parser.js +339 -0
- package/dist/parser/change-parser.js.map +1 -0
- package/dist/parser/markdown-utils.d.ts +138 -0
- package/dist/parser/markdown-utils.d.ts.map +1 -0
- package/dist/parser/markdown-utils.js +283 -0
- package/dist/parser/markdown-utils.js.map +1 -0
- package/dist/parser/spec-parser.d.ts +116 -0
- package/dist/parser/spec-parser.d.ts.map +1 -0
- package/dist/parser/spec-parser.js +204 -0
- package/dist/parser/spec-parser.js.map +1 -0
- package/dist/parser/tasks-parser.d.ts +120 -0
- package/dist/parser/tasks-parser.d.ts.map +1 -0
- package/dist/parser/tasks-parser.js +176 -0
- package/dist/parser/tasks-parser.js.map +1 -0
- package/dist/watcher.d.ts +160 -0
- package/dist/watcher.d.ts.map +1 -0
- package/dist/watcher.js +614 -0
- package/dist/watcher.js.map +1 -0
- package/dist/writer/index.d.ts +9 -0
- package/dist/writer/index.d.ts.map +1 -0
- package/dist/writer/index.js +9 -0
- package/dist/writer/index.js.map +1 -0
- package/dist/writer/spec-writer.d.ts +24 -0
- package/dist/writer/spec-writer.d.ts.map +1 -0
- package/dist/writer/spec-writer.js +75 -0
- package/dist/writer/spec-writer.js.map +1 -0
- package/dist/writer/tasks-writer.d.ts +33 -0
- package/dist/writer/tasks-writer.d.ts.map +1 -0
- package/dist/writer/tasks-writer.js +144 -0
- package/dist/writer/tasks-writer.js.map +1 -0
- package/package.json +43 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenSpec Integration Plugin for sudocode
|
|
3
|
+
*
|
|
4
|
+
* Provides integration with OpenSpec - a standardized specification format
|
|
5
|
+
* for AI-assisted development. Syncs specs and issues to sudocode.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
import { createHash } from "crypto";
|
|
10
|
+
// Import parsers
|
|
11
|
+
import { parseSpecFile, } from "./parser/spec-parser.js";
|
|
12
|
+
import { parseChangeDirectory, scanChangeDirectories, } from "./parser/change-parser.js";
|
|
13
|
+
import { generateSpecId, generateChangeId, parseOpenSpecId } from "./id-generator.js";
|
|
14
|
+
import { OpenSpecWatcher } from "./watcher.js";
|
|
15
|
+
// Import writers for bidirectional sync
|
|
16
|
+
import { updateAllTasksCompletion, updateSpecContent } from "./writer/index.js";
|
|
17
|
+
/**
|
|
18
|
+
* Configuration schema for UI form generation
|
|
19
|
+
*/
|
|
20
|
+
const configSchema = {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
path: {
|
|
24
|
+
type: "string",
|
|
25
|
+
title: "OpenSpec Path",
|
|
26
|
+
description: "Path to the OpenSpec directory (relative to project root)",
|
|
27
|
+
default: ".openspec",
|
|
28
|
+
required: true,
|
|
29
|
+
},
|
|
30
|
+
spec_prefix: {
|
|
31
|
+
type: "string",
|
|
32
|
+
title: "Spec Prefix",
|
|
33
|
+
description: "Prefix for spec IDs imported from OpenSpec",
|
|
34
|
+
default: "os",
|
|
35
|
+
},
|
|
36
|
+
issue_prefix: {
|
|
37
|
+
type: "string",
|
|
38
|
+
title: "Issue Prefix",
|
|
39
|
+
description: "Prefix for issue IDs imported from OpenSpec",
|
|
40
|
+
default: "osi",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
required: ["path"],
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* OpenSpec integration plugin
|
|
47
|
+
*/
|
|
48
|
+
const openSpecPlugin = {
|
|
49
|
+
name: "openspec",
|
|
50
|
+
displayName: "OpenSpec",
|
|
51
|
+
version: "0.1.0",
|
|
52
|
+
description: "Integration with OpenSpec standardized specification format",
|
|
53
|
+
configSchema,
|
|
54
|
+
validateConfig(options) {
|
|
55
|
+
const errors = [];
|
|
56
|
+
const warnings = [];
|
|
57
|
+
// Check required path field
|
|
58
|
+
if (!options.path || typeof options.path !== "string") {
|
|
59
|
+
errors.push("openspec.options.path is required");
|
|
60
|
+
}
|
|
61
|
+
// Validate spec_prefix if provided
|
|
62
|
+
if (options.spec_prefix !== undefined) {
|
|
63
|
+
if (typeof options.spec_prefix !== "string") {
|
|
64
|
+
errors.push("openspec.options.spec_prefix must be a string");
|
|
65
|
+
}
|
|
66
|
+
else if (!/^[a-z]{1,4}$/i.test(options.spec_prefix)) {
|
|
67
|
+
warnings.push("openspec.options.spec_prefix should be 1-4 alphabetic characters");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Validate issue_prefix if provided
|
|
71
|
+
if (options.issue_prefix !== undefined) {
|
|
72
|
+
if (typeof options.issue_prefix !== "string") {
|
|
73
|
+
errors.push("openspec.options.issue_prefix must be a string");
|
|
74
|
+
}
|
|
75
|
+
else if (!/^[a-z]{1,4}$/i.test(options.issue_prefix)) {
|
|
76
|
+
warnings.push("openspec.options.issue_prefix should be 1-4 alphabetic characters");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
valid: errors.length === 0,
|
|
81
|
+
errors,
|
|
82
|
+
warnings,
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
async testConnection(options, projectPath) {
|
|
86
|
+
const openSpecPath = options.path;
|
|
87
|
+
if (!openSpecPath) {
|
|
88
|
+
return {
|
|
89
|
+
success: false,
|
|
90
|
+
configured: true,
|
|
91
|
+
enabled: true,
|
|
92
|
+
error: "OpenSpec path is not configured",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const resolvedPath = path.resolve(projectPath, openSpecPath);
|
|
96
|
+
if (!existsSync(resolvedPath)) {
|
|
97
|
+
return {
|
|
98
|
+
success: false,
|
|
99
|
+
configured: true,
|
|
100
|
+
enabled: true,
|
|
101
|
+
error: `OpenSpec directory not found: ${resolvedPath}`,
|
|
102
|
+
details: { path: openSpecPath, resolvedPath },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
success: true,
|
|
107
|
+
configured: true,
|
|
108
|
+
enabled: true,
|
|
109
|
+
details: {
|
|
110
|
+
path: openSpecPath,
|
|
111
|
+
resolvedPath,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
createProvider(options, projectPath) {
|
|
116
|
+
return new OpenSpecProvider(options, projectPath);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* OpenSpec provider implementation
|
|
121
|
+
*/
|
|
122
|
+
class OpenSpecProvider {
|
|
123
|
+
name = "openspec";
|
|
124
|
+
supportsWatch = true;
|
|
125
|
+
supportsPolling = true;
|
|
126
|
+
options;
|
|
127
|
+
projectPath;
|
|
128
|
+
resolvedPath;
|
|
129
|
+
// Change tracking for getChangesSince
|
|
130
|
+
entityHashes = new Map();
|
|
131
|
+
// File watcher instance
|
|
132
|
+
watcher = null;
|
|
133
|
+
constructor(options, projectPath) {
|
|
134
|
+
this.options = options;
|
|
135
|
+
this.projectPath = projectPath;
|
|
136
|
+
this.resolvedPath = path.resolve(projectPath, options.path);
|
|
137
|
+
}
|
|
138
|
+
async initialize() {
|
|
139
|
+
console.log(`[openspec] Initializing provider for path: ${this.resolvedPath}`);
|
|
140
|
+
if (!existsSync(this.resolvedPath)) {
|
|
141
|
+
throw new Error(`OpenSpec directory not found: ${this.resolvedPath}`);
|
|
142
|
+
}
|
|
143
|
+
console.log(`[openspec] Provider initialized successfully`);
|
|
144
|
+
}
|
|
145
|
+
async validate() {
|
|
146
|
+
const errors = [];
|
|
147
|
+
if (!existsSync(this.resolvedPath)) {
|
|
148
|
+
errors.push(`OpenSpec directory not found: ${this.resolvedPath}`);
|
|
149
|
+
return { valid: false, errors };
|
|
150
|
+
}
|
|
151
|
+
const valid = errors.length === 0;
|
|
152
|
+
console.log(`[openspec] Validation result: valid=${valid}, errors=${errors.length}`);
|
|
153
|
+
return { valid, errors };
|
|
154
|
+
}
|
|
155
|
+
async dispose() {
|
|
156
|
+
console.log(`[openspec] Disposing provider`);
|
|
157
|
+
this.stopWatching();
|
|
158
|
+
this.entityHashes.clear();
|
|
159
|
+
console.log(`[openspec] Provider disposed successfully`);
|
|
160
|
+
}
|
|
161
|
+
async fetchEntity(externalId) {
|
|
162
|
+
console.log(`[openspec] fetchEntity called for: ${externalId}`);
|
|
163
|
+
// Parse the external ID to determine entity type
|
|
164
|
+
const parsed = parseOpenSpecId(externalId);
|
|
165
|
+
if (!parsed) {
|
|
166
|
+
console.warn(`[openspec] Invalid ID format: ${externalId}`);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const specPrefix = this.options.spec_prefix || "os";
|
|
170
|
+
const issuePrefix = this.options.issue_prefix || "osc";
|
|
171
|
+
// Check if this is a spec or change (issue)
|
|
172
|
+
if (parsed.type === "spec") {
|
|
173
|
+
// Search for spec with matching ID
|
|
174
|
+
const specsDir = path.join(this.resolvedPath, "specs");
|
|
175
|
+
if (!existsSync(specsDir)) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const entries = readdirSync(specsDir, { withFileTypes: true });
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
if (!entry.isDirectory())
|
|
182
|
+
continue;
|
|
183
|
+
const specPath = path.join(specsDir, entry.name, "spec.md");
|
|
184
|
+
if (!existsSync(specPath))
|
|
185
|
+
continue;
|
|
186
|
+
const generatedId = generateSpecId(entry.name, specPrefix);
|
|
187
|
+
if (generatedId === externalId) {
|
|
188
|
+
const spec = parseSpecFile(specPath);
|
|
189
|
+
return this.specToExternalEntity(spec, generatedId);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
console.error(`[openspec] Error fetching spec:`, error);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else if (parsed.type === "change") {
|
|
198
|
+
// Search for change with matching ID
|
|
199
|
+
const changesDir = path.join(this.resolvedPath, "changes");
|
|
200
|
+
if (!existsSync(changesDir)) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const changePaths = scanChangeDirectories(changesDir, true);
|
|
205
|
+
for (const changePath of changePaths) {
|
|
206
|
+
const change = parseChangeDirectory(changePath);
|
|
207
|
+
const generatedId = generateChangeId(change.name, issuePrefix);
|
|
208
|
+
if (generatedId === externalId) {
|
|
209
|
+
return this.changeToExternalEntity(change, generatedId);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
console.error(`[openspec] Error fetching change:`, error);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
async searchEntities(query) {
|
|
220
|
+
console.log(`[openspec] searchEntities called with query: ${query}`);
|
|
221
|
+
// IMPORTANT: We collect specs FIRST, then issues
|
|
222
|
+
// This ensures specs exist before issues that reference them are synced
|
|
223
|
+
const specEntities = [];
|
|
224
|
+
const issueEntities = [];
|
|
225
|
+
const specPrefix = this.options.spec_prefix || "os";
|
|
226
|
+
const issuePrefix = this.options.issue_prefix || "osc";
|
|
227
|
+
// Track which specs exist in openspec/specs/ (approved specs)
|
|
228
|
+
const approvedSpecs = new Set();
|
|
229
|
+
// Scan specs/ directory for OpenSpec specs (approved/current)
|
|
230
|
+
const specsDir = path.join(this.resolvedPath, "specs");
|
|
231
|
+
if (existsSync(specsDir)) {
|
|
232
|
+
try {
|
|
233
|
+
const entries = readdirSync(specsDir, { withFileTypes: true });
|
|
234
|
+
for (const entry of entries) {
|
|
235
|
+
if (!entry.isDirectory())
|
|
236
|
+
continue;
|
|
237
|
+
const specPath = path.join(specsDir, entry.name, "spec.md");
|
|
238
|
+
if (!existsSync(specPath))
|
|
239
|
+
continue;
|
|
240
|
+
approvedSpecs.add(entry.name);
|
|
241
|
+
try {
|
|
242
|
+
const spec = parseSpecFile(specPath);
|
|
243
|
+
const specId = generateSpecId(entry.name, specPrefix);
|
|
244
|
+
const entity = this.specToExternalEntity(spec, specId);
|
|
245
|
+
if (this.matchesQuery(entity, query)) {
|
|
246
|
+
specEntities.push(entity);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
console.error(`[openspec] Error parsing spec at ${specPath}:`, error);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
console.error(`[openspec] Error scanning specs directory:`, error);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Scan changes/ directory for OpenSpec changes (map to issues)
|
|
259
|
+
// Also extract proposed specs from changes/[name]/specs/[cap]/spec.md
|
|
260
|
+
const changesDir = path.join(this.resolvedPath, "changes");
|
|
261
|
+
if (existsSync(changesDir)) {
|
|
262
|
+
try {
|
|
263
|
+
const changePaths = scanChangeDirectories(changesDir, true);
|
|
264
|
+
for (const changePath of changePaths) {
|
|
265
|
+
try {
|
|
266
|
+
const change = parseChangeDirectory(changePath);
|
|
267
|
+
const changeId = generateChangeId(change.name, issuePrefix);
|
|
268
|
+
const entity = this.changeToExternalEntity(change, changeId);
|
|
269
|
+
if (this.matchesQuery(entity, query)) {
|
|
270
|
+
issueEntities.push(entity);
|
|
271
|
+
}
|
|
272
|
+
// Scan for proposed specs inside this change
|
|
273
|
+
// These are NEW specs or deltas in changes/[name]/specs/[cap]/spec.md
|
|
274
|
+
const changeSpecsDir = path.join(changePath, "specs");
|
|
275
|
+
if (existsSync(changeSpecsDir)) {
|
|
276
|
+
const specDirEntries = readdirSync(changeSpecsDir, { withFileTypes: true });
|
|
277
|
+
for (const specEntry of specDirEntries) {
|
|
278
|
+
if (!specEntry.isDirectory())
|
|
279
|
+
continue;
|
|
280
|
+
const proposedSpecPath = path.join(changeSpecsDir, specEntry.name, "spec.md");
|
|
281
|
+
if (!existsSync(proposedSpecPath))
|
|
282
|
+
continue;
|
|
283
|
+
// Check if this is a NEW spec (not in openspec/specs/) or a delta
|
|
284
|
+
const isNewSpec = !approvedSpecs.has(specEntry.name);
|
|
285
|
+
try {
|
|
286
|
+
const proposedSpec = parseSpecFile(proposedSpecPath);
|
|
287
|
+
const proposedSpecId = generateSpecId(specEntry.name, specPrefix);
|
|
288
|
+
// Only create a separate spec entity for NEW specs
|
|
289
|
+
// Deltas to existing specs are just tracked via relationships
|
|
290
|
+
if (isNewSpec) {
|
|
291
|
+
const proposedEntity = this.proposedSpecToExternalEntity(proposedSpec, proposedSpecId, changeId, change.name);
|
|
292
|
+
if (this.matchesQuery(proposedEntity, query)) {
|
|
293
|
+
// Add proposed specs to specEntities so they're synced before issues
|
|
294
|
+
specEntities.push(proposedEntity);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
console.error(`[openspec] Error parsing proposed spec at ${proposedSpecPath}:`, error);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
console.error(`[openspec] Error parsing change at ${changePath}:`, error);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
console.error(`[openspec] Error scanning changes directory:`, error);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Return specs FIRST, then issues
|
|
314
|
+
// This ensures specs are created before issues that implement them
|
|
315
|
+
const entities = [...specEntities, ...issueEntities];
|
|
316
|
+
console.log(`[openspec] searchEntities found ${entities.length} entities (${specEntities.length} specs, ${issueEntities.length} issues)`);
|
|
317
|
+
return entities;
|
|
318
|
+
}
|
|
319
|
+
async createEntity(entity) {
|
|
320
|
+
console.log(`[openspec] createEntity called:`, entity.title);
|
|
321
|
+
throw new Error("createEntity not supported: OpenSpec entities are created by adding files to the .openspec directory");
|
|
322
|
+
}
|
|
323
|
+
async updateEntity(externalId, entity) {
|
|
324
|
+
console.log(`[openspec] updateEntity called for ${externalId}:`, JSON.stringify(entity));
|
|
325
|
+
// Find the entity in our current state to get file paths
|
|
326
|
+
const currentEntities = await this.searchEntities();
|
|
327
|
+
const targetEntity = currentEntities.find((e) => e.id === externalId);
|
|
328
|
+
if (!targetEntity) {
|
|
329
|
+
console.error(`[openspec] Entity not found: ${externalId}`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (targetEntity.type === "spec") {
|
|
333
|
+
// Update spec.md file
|
|
334
|
+
const rawData = targetEntity.raw;
|
|
335
|
+
const filePath = rawData?.filePath;
|
|
336
|
+
if (filePath && entity.content !== undefined) {
|
|
337
|
+
updateSpecContent(filePath, entity.content);
|
|
338
|
+
console.log(`[openspec] Updated spec at ${filePath}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else if (targetEntity.type === "issue") {
|
|
342
|
+
// Update change files (tasks.md)
|
|
343
|
+
const rawData = targetEntity.raw;
|
|
344
|
+
const changeName = rawData?.name;
|
|
345
|
+
if (changeName) {
|
|
346
|
+
await this.updateChangeByName(changeName, entity);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Update watcher hash cache to prevent detecting our write as a change
|
|
350
|
+
if (this.watcher) {
|
|
351
|
+
const refreshedEntities = await this.searchEntities();
|
|
352
|
+
const updatedEntity = refreshedEntities.find((e) => e.id === externalId);
|
|
353
|
+
if (updatedEntity) {
|
|
354
|
+
const newHash = this.computeEntityHash(updatedEntity);
|
|
355
|
+
this.watcher.updateEntityHash(externalId, newHash);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// Refresh entity hash cache
|
|
359
|
+
for (const e of currentEntities) {
|
|
360
|
+
this.entityHashes.set(e.id, this.computeEntityHash(e));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Update a change's files (tasks.md) with changes from sudocode
|
|
365
|
+
*/
|
|
366
|
+
async updateChangeByName(changeName, entity) {
|
|
367
|
+
// Find change directory
|
|
368
|
+
let changePath = null;
|
|
369
|
+
const changesDir = path.join(this.resolvedPath, "changes");
|
|
370
|
+
if (existsSync(changesDir)) {
|
|
371
|
+
const changePaths = scanChangeDirectories(changesDir, true);
|
|
372
|
+
for (const cp of changePaths) {
|
|
373
|
+
if (path.basename(cp) === changeName) {
|
|
374
|
+
changePath = cp;
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (!changePath) {
|
|
380
|
+
console.error(`[openspec] Change directory not found: ${changeName}`);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// Handle status changes - update tasks.md checkboxes
|
|
384
|
+
if (entity.status !== undefined) {
|
|
385
|
+
const tasksPath = path.join(changePath, "tasks.md");
|
|
386
|
+
if (existsSync(tasksPath)) {
|
|
387
|
+
// Mark all tasks as completed when issue is closed
|
|
388
|
+
const completed = entity.status === "closed";
|
|
389
|
+
if (completed) {
|
|
390
|
+
updateAllTasksCompletion(tasksPath, true);
|
|
391
|
+
console.log(`[openspec] Marked all tasks as completed in ${tasksPath}`);
|
|
392
|
+
}
|
|
393
|
+
// Note: We don't uncheck tasks when reopening - that would be destructive
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
console.log(`[openspec] Updated change at ${changePath}`);
|
|
397
|
+
}
|
|
398
|
+
async deleteEntity(externalId) {
|
|
399
|
+
console.log(`[openspec] deleteEntity called for: ${externalId}`);
|
|
400
|
+
throw new Error("deleteEntity not supported: OpenSpec entities are deleted by removing files from the .openspec directory");
|
|
401
|
+
}
|
|
402
|
+
async getChangesSince(timestamp) {
|
|
403
|
+
console.log(`[openspec] getChangesSince called for: ${timestamp.toISOString()}`);
|
|
404
|
+
const changes = [];
|
|
405
|
+
const currentEntities = await this.searchEntities();
|
|
406
|
+
const currentIds = new Set();
|
|
407
|
+
// Check for created and updated entities
|
|
408
|
+
for (const entity of currentEntities) {
|
|
409
|
+
currentIds.add(entity.id);
|
|
410
|
+
const newHash = this.computeEntityHash(entity);
|
|
411
|
+
const cachedHash = this.entityHashes.get(entity.id);
|
|
412
|
+
if (!cachedHash) {
|
|
413
|
+
// New entity
|
|
414
|
+
changes.push({
|
|
415
|
+
entity_id: entity.id,
|
|
416
|
+
entity_type: entity.type,
|
|
417
|
+
change_type: "created",
|
|
418
|
+
timestamp: entity.created_at || new Date().toISOString(),
|
|
419
|
+
data: entity,
|
|
420
|
+
});
|
|
421
|
+
this.entityHashes.set(entity.id, newHash);
|
|
422
|
+
}
|
|
423
|
+
else if (newHash !== cachedHash) {
|
|
424
|
+
// Updated entity
|
|
425
|
+
changes.push({
|
|
426
|
+
entity_id: entity.id,
|
|
427
|
+
entity_type: entity.type,
|
|
428
|
+
change_type: "updated",
|
|
429
|
+
timestamp: entity.updated_at || new Date().toISOString(),
|
|
430
|
+
data: entity,
|
|
431
|
+
});
|
|
432
|
+
this.entityHashes.set(entity.id, newHash);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// Check for deleted entities
|
|
436
|
+
const now = new Date().toISOString();
|
|
437
|
+
for (const [id, _hash] of this.entityHashes) {
|
|
438
|
+
if (!currentIds.has(id)) {
|
|
439
|
+
// Determine entity type from ID prefix
|
|
440
|
+
const isIssue = id.startsWith(this.options.issue_prefix || "osi");
|
|
441
|
+
changes.push({
|
|
442
|
+
entity_id: id,
|
|
443
|
+
entity_type: isIssue ? "issue" : "spec",
|
|
444
|
+
change_type: "deleted",
|
|
445
|
+
timestamp: now,
|
|
446
|
+
});
|
|
447
|
+
this.entityHashes.delete(id);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
console.log(`[openspec] getChangesSince found ${changes.length} changes`);
|
|
451
|
+
return changes;
|
|
452
|
+
}
|
|
453
|
+
startWatching(callback) {
|
|
454
|
+
console.log(`[openspec] startWatching called`);
|
|
455
|
+
if (this.watcher) {
|
|
456
|
+
console.warn("[openspec] Already watching");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
this.watcher = new OpenSpecWatcher({
|
|
460
|
+
openspecPath: this.resolvedPath,
|
|
461
|
+
specPrefix: this.options.spec_prefix,
|
|
462
|
+
changePrefix: this.options.issue_prefix,
|
|
463
|
+
trackArchived: true,
|
|
464
|
+
debounceMs: 100,
|
|
465
|
+
});
|
|
466
|
+
this.watcher.start(callback);
|
|
467
|
+
console.log(`[openspec] File watching started for ${this.resolvedPath}`);
|
|
468
|
+
}
|
|
469
|
+
stopWatching() {
|
|
470
|
+
console.log(`[openspec] stopWatching called`);
|
|
471
|
+
if (this.watcher) {
|
|
472
|
+
this.watcher.stop();
|
|
473
|
+
this.watcher = null;
|
|
474
|
+
console.log(`[openspec] File watching stopped`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
mapToSudocode(external) {
|
|
478
|
+
if (external.type === "issue") {
|
|
479
|
+
return {
|
|
480
|
+
issue: {
|
|
481
|
+
title: external.title,
|
|
482
|
+
content: external.description || "",
|
|
483
|
+
priority: external.priority ?? 2,
|
|
484
|
+
status: this.mapStatus(external.status),
|
|
485
|
+
},
|
|
486
|
+
// Pass through relationships for change→spec implements links
|
|
487
|
+
relationships: external.relationships,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
spec: {
|
|
492
|
+
title: external.title,
|
|
493
|
+
content: external.description || "",
|
|
494
|
+
priority: external.priority ?? 2,
|
|
495
|
+
},
|
|
496
|
+
// Pass through relationships for proposed specs (references to change)
|
|
497
|
+
relationships: external.relationships,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
mapFromSudocode(entity) {
|
|
501
|
+
const isIssue = "status" in entity;
|
|
502
|
+
return {
|
|
503
|
+
type: isIssue ? "issue" : "spec",
|
|
504
|
+
title: entity.title,
|
|
505
|
+
description: entity.content,
|
|
506
|
+
priority: entity.priority,
|
|
507
|
+
status: isIssue ? entity.status : undefined,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
mapStatus(externalStatus) {
|
|
511
|
+
if (!externalStatus)
|
|
512
|
+
return "open";
|
|
513
|
+
const statusMap = {
|
|
514
|
+
open: "open",
|
|
515
|
+
in_progress: "in_progress",
|
|
516
|
+
blocked: "blocked",
|
|
517
|
+
needs_review: "needs_review",
|
|
518
|
+
closed: "closed",
|
|
519
|
+
done: "closed",
|
|
520
|
+
completed: "closed",
|
|
521
|
+
};
|
|
522
|
+
return statusMap[externalStatus.toLowerCase()] || "open";
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Compute a hash for an entity to detect changes
|
|
526
|
+
*/
|
|
527
|
+
computeEntityHash(entity) {
|
|
528
|
+
const canonical = JSON.stringify({
|
|
529
|
+
id: entity.id,
|
|
530
|
+
type: entity.type,
|
|
531
|
+
title: entity.title,
|
|
532
|
+
description: entity.description,
|
|
533
|
+
status: entity.status,
|
|
534
|
+
priority: entity.priority,
|
|
535
|
+
});
|
|
536
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
537
|
+
}
|
|
538
|
+
// ===========================================================================
|
|
539
|
+
// Entity Conversion Helpers
|
|
540
|
+
// ===========================================================================
|
|
541
|
+
/**
|
|
542
|
+
* Convert a parsed OpenSpec spec to ExternalEntity
|
|
543
|
+
*/
|
|
544
|
+
specToExternalEntity(spec, id) {
|
|
545
|
+
// Read raw file content for description
|
|
546
|
+
let rawContent = spec.rawContent;
|
|
547
|
+
try {
|
|
548
|
+
rawContent = readFileSync(spec.filePath, "utf-8");
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
// Fall back to parsed content
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
id,
|
|
555
|
+
type: "spec",
|
|
556
|
+
title: spec.title,
|
|
557
|
+
description: rawContent,
|
|
558
|
+
priority: 2, // Default priority
|
|
559
|
+
raw: {
|
|
560
|
+
capability: spec.capability,
|
|
561
|
+
purpose: spec.purpose,
|
|
562
|
+
requirements: spec.requirements,
|
|
563
|
+
filePath: spec.filePath,
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Convert a proposed spec (from changes/[name]/specs/) to ExternalEntity
|
|
569
|
+
*
|
|
570
|
+
* Proposed specs are NEW specs that don't exist in openspec/specs/ yet.
|
|
571
|
+
* They are marked with a "proposed" tag. The change has the implements
|
|
572
|
+
* relationship to the spec (not bidirectional).
|
|
573
|
+
*/
|
|
574
|
+
proposedSpecToExternalEntity(spec, id, _changeId, changeName) {
|
|
575
|
+
// Read raw file content for description
|
|
576
|
+
let rawContent = spec.rawContent;
|
|
577
|
+
try {
|
|
578
|
+
rawContent = readFileSync(spec.filePath, "utf-8");
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
// Fall back to parsed content
|
|
582
|
+
}
|
|
583
|
+
return {
|
|
584
|
+
id,
|
|
585
|
+
type: "spec",
|
|
586
|
+
title: spec.title,
|
|
587
|
+
description: rawContent,
|
|
588
|
+
priority: 2,
|
|
589
|
+
raw: {
|
|
590
|
+
capability: spec.capability,
|
|
591
|
+
purpose: spec.purpose,
|
|
592
|
+
requirements: spec.requirements,
|
|
593
|
+
filePath: spec.filePath,
|
|
594
|
+
isProposed: true,
|
|
595
|
+
proposedByChange: changeName,
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Convert a parsed OpenSpec change to ExternalEntity (as issue)
|
|
601
|
+
*
|
|
602
|
+
* Changes map to sudocode Issues:
|
|
603
|
+
* - Archived changes → status: "closed"
|
|
604
|
+
* - Active changes with 100% task completion → status: "needs_review"
|
|
605
|
+
* - Active changes with progress → status: "in_progress"
|
|
606
|
+
* - Active changes with no progress → status: "open"
|
|
607
|
+
*/
|
|
608
|
+
changeToExternalEntity(change, id) {
|
|
609
|
+
// Determine status based on archive and task completion
|
|
610
|
+
let status;
|
|
611
|
+
if (change.isArchived) {
|
|
612
|
+
status = "closed";
|
|
613
|
+
}
|
|
614
|
+
else if (change.taskCompletion === 100) {
|
|
615
|
+
status = "needs_review";
|
|
616
|
+
}
|
|
617
|
+
else if (change.taskCompletion > 0) {
|
|
618
|
+
status = "in_progress";
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
status = "open";
|
|
622
|
+
}
|
|
623
|
+
// Build description from proposal content
|
|
624
|
+
const descriptionParts = [];
|
|
625
|
+
if (change.why) {
|
|
626
|
+
descriptionParts.push(`## Why\n${change.why}`);
|
|
627
|
+
}
|
|
628
|
+
if (change.whatChanges) {
|
|
629
|
+
descriptionParts.push(`## What Changes\n${change.whatChanges}`);
|
|
630
|
+
}
|
|
631
|
+
if (change.impact) {
|
|
632
|
+
descriptionParts.push(`## Impact\n${change.impact}`);
|
|
633
|
+
}
|
|
634
|
+
// Add task summary
|
|
635
|
+
if (change.tasks.length > 0) {
|
|
636
|
+
const taskSummary = `## Tasks\n- ${change.tasks.length} total tasks\n- ${change.taskCompletion}% complete`;
|
|
637
|
+
descriptionParts.push(taskSummary);
|
|
638
|
+
}
|
|
639
|
+
const description = descriptionParts.join("\n\n");
|
|
640
|
+
// Build relationships from affected specs
|
|
641
|
+
const specPrefix = this.options.spec_prefix || "os";
|
|
642
|
+
const relationships = change.affectedSpecs.map((specCapability) => ({
|
|
643
|
+
targetId: generateSpecId(specCapability, specPrefix),
|
|
644
|
+
targetType: "spec",
|
|
645
|
+
relationshipType: "implements",
|
|
646
|
+
}));
|
|
647
|
+
return {
|
|
648
|
+
id,
|
|
649
|
+
type: "issue",
|
|
650
|
+
title: change.title,
|
|
651
|
+
description,
|
|
652
|
+
status,
|
|
653
|
+
priority: change.isArchived ? 4 : 2, // Lower priority for archived
|
|
654
|
+
created_at: change.archivedAt?.toISOString(),
|
|
655
|
+
relationships: relationships.length > 0 ? relationships : undefined,
|
|
656
|
+
raw: {
|
|
657
|
+
name: change.name,
|
|
658
|
+
why: change.why,
|
|
659
|
+
whatChanges: change.whatChanges,
|
|
660
|
+
impact: change.impact,
|
|
661
|
+
tasks: change.tasks,
|
|
662
|
+
taskCompletion: change.taskCompletion,
|
|
663
|
+
affectedSpecs: change.affectedSpecs,
|
|
664
|
+
isArchived: change.isArchived,
|
|
665
|
+
archivedAt: change.archivedAt,
|
|
666
|
+
filePath: change.filePath,
|
|
667
|
+
},
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Check if an entity matches a query string
|
|
672
|
+
*/
|
|
673
|
+
matchesQuery(entity, query) {
|
|
674
|
+
if (!query)
|
|
675
|
+
return true;
|
|
676
|
+
const lowerQuery = query.toLowerCase();
|
|
677
|
+
return (entity.title.toLowerCase().includes(lowerQuery) ||
|
|
678
|
+
(entity.description?.toLowerCase().includes(lowerQuery) ?? false));
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
export default openSpecPlugin;
|
|
682
|
+
// Re-export ID generator functions for use by consumers
|
|
683
|
+
export { generateSpecId, generateChangeId, parseOpenSpecId, verifyOpenSpecId, isOpenSpecId, DEFAULT_SPEC_PREFIX, DEFAULT_CHANGE_PREFIX, } from "./id-generator.js";
|
|
684
|
+
// Re-export spec parser functions and types for use by consumers
|
|
685
|
+
export { parseSpecFile, extractCapability, parseRequirements, parseScenarios, parseGivenWhenThen, SPEC_PATTERNS, } from "./parser/spec-parser.js";
|
|
686
|
+
// Re-export tasks parser functions and types for use by consumers
|
|
687
|
+
export { parseTasks, parseTasksContent, getAllTasks, getIncompleteTasks, getTaskStats, calculateCompletionPercentage, isTasksFile, TASK_PATTERNS, } from "./parser/tasks-parser.js";
|
|
688
|
+
// Re-export change parser functions and types for use by consumers
|
|
689
|
+
export { parseChangeDirectory, extractChangeName, detectArchiveStatus, parseProposal, extractTitleFromWhatChanges, formatTitle, parseChangeTasks, scanAffectedSpecs, isChangeDirectory, scanChangeDirectories, parseAllChanges, CHANGE_PATTERNS, } from "./parser/change-parser.js";
|
|
690
|
+
// Re-export watcher for use by consumers
|
|
691
|
+
export { OpenSpecWatcher, } from "./watcher.js";
|
|
692
|
+
//# sourceMappingURL=index.js.map
|