@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.
Files changed (41) hide show
  1. package/dist/id-generator.d.ts +114 -0
  2. package/dist/id-generator.d.ts.map +1 -0
  3. package/dist/id-generator.js +165 -0
  4. package/dist/id-generator.js.map +1 -0
  5. package/dist/index.d.ts +29 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +692 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/parser/change-parser.d.ts +164 -0
  10. package/dist/parser/change-parser.d.ts.map +1 -0
  11. package/dist/parser/change-parser.js +339 -0
  12. package/dist/parser/change-parser.js.map +1 -0
  13. package/dist/parser/markdown-utils.d.ts +138 -0
  14. package/dist/parser/markdown-utils.d.ts.map +1 -0
  15. package/dist/parser/markdown-utils.js +283 -0
  16. package/dist/parser/markdown-utils.js.map +1 -0
  17. package/dist/parser/spec-parser.d.ts +116 -0
  18. package/dist/parser/spec-parser.d.ts.map +1 -0
  19. package/dist/parser/spec-parser.js +204 -0
  20. package/dist/parser/spec-parser.js.map +1 -0
  21. package/dist/parser/tasks-parser.d.ts +120 -0
  22. package/dist/parser/tasks-parser.d.ts.map +1 -0
  23. package/dist/parser/tasks-parser.js +176 -0
  24. package/dist/parser/tasks-parser.js.map +1 -0
  25. package/dist/watcher.d.ts +160 -0
  26. package/dist/watcher.d.ts.map +1 -0
  27. package/dist/watcher.js +614 -0
  28. package/dist/watcher.js.map +1 -0
  29. package/dist/writer/index.d.ts +9 -0
  30. package/dist/writer/index.d.ts.map +1 -0
  31. package/dist/writer/index.js +9 -0
  32. package/dist/writer/index.js.map +1 -0
  33. package/dist/writer/spec-writer.d.ts +24 -0
  34. package/dist/writer/spec-writer.d.ts.map +1 -0
  35. package/dist/writer/spec-writer.js +75 -0
  36. package/dist/writer/spec-writer.js.map +1 -0
  37. package/dist/writer/tasks-writer.d.ts +33 -0
  38. package/dist/writer/tasks-writer.d.ts.map +1 -0
  39. package/dist/writer/tasks-writer.js +144 -0
  40. package/dist/writer/tasks-writer.js.map +1 -0
  41. 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