@fission-ai/openspec 1.2.0 → 1.3.1

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 (54) hide show
  1. package/README.md +8 -13
  2. package/dist/commands/workflow/instructions.js +18 -72
  3. package/dist/commands/workflow/shared.d.ts +6 -1
  4. package/dist/commands/workflow/shared.js +21 -16
  5. package/dist/commands/workflow/status.js +21 -4
  6. package/dist/commands/workflow/templates.js +5 -4
  7. package/dist/core/artifact-graph/index.d.ts +1 -0
  8. package/dist/core/artifact-graph/index.js +1 -0
  9. package/dist/core/artifact-graph/instruction-loader.js +6 -4
  10. package/dist/core/artifact-graph/outputs.d.ts +14 -0
  11. package/dist/core/artifact-graph/outputs.js +39 -0
  12. package/dist/core/artifact-graph/state.js +2 -25
  13. package/dist/core/available-tools.d.ts +3 -2
  14. package/dist/core/available-tools.js +15 -2
  15. package/dist/core/command-generation/adapters/bob.d.ts +14 -0
  16. package/dist/core/command-generation/adapters/bob.js +45 -0
  17. package/dist/core/command-generation/adapters/index.d.ts +3 -0
  18. package/dist/core/command-generation/adapters/index.js +3 -0
  19. package/dist/core/command-generation/adapters/junie.d.ts +13 -0
  20. package/dist/core/command-generation/adapters/junie.js +26 -0
  21. package/dist/core/command-generation/adapters/lingma.d.ts +13 -0
  22. package/dist/core/command-generation/adapters/lingma.js +30 -0
  23. package/dist/core/command-generation/adapters/opencode.d.ts +1 -1
  24. package/dist/core/command-generation/adapters/opencode.js +2 -2
  25. package/dist/core/command-generation/adapters/pi.d.ts +4 -0
  26. package/dist/core/command-generation/adapters/pi.js +15 -1
  27. package/dist/core/command-generation/registry.js +6 -0
  28. package/dist/core/completions/installers/powershell-installer.d.ts +14 -0
  29. package/dist/core/completions/installers/powershell-installer.js +69 -9
  30. package/dist/core/config.d.ts +1 -0
  31. package/dist/core/config.js +5 -1
  32. package/dist/core/init.js +6 -10
  33. package/dist/core/legacy-cleanup.d.ts +1 -1
  34. package/dist/core/legacy-cleanup.js +22 -10
  35. package/dist/core/parsers/change-parser.js +7 -3
  36. package/dist/core/parsers/markdown-parser.d.ts +4 -0
  37. package/dist/core/parsers/markdown-parser.js +41 -1
  38. package/dist/core/parsers/spec-structure.d.ts +9 -0
  39. package/dist/core/parsers/spec-structure.js +88 -0
  40. package/dist/core/specs-apply.js +8 -0
  41. package/dist/core/templates/workflows/apply-change.js +4 -4
  42. package/dist/core/templates/workflows/bulk-archive-change.js +2 -2
  43. package/dist/core/templates/workflows/explore.js +25 -25
  44. package/dist/core/templates/workflows/onboard.js +24 -24
  45. package/dist/core/templates/workflows/verify-change.js +6 -6
  46. package/dist/core/update.js +2 -2
  47. package/dist/core/validation/validator.js +9 -0
  48. package/dist/telemetry/config.d.ts +7 -1
  49. package/dist/telemetry/config.js +87 -19
  50. package/dist/telemetry/index.js +19 -0
  51. package/dist/utils/file-system.d.ts +5 -0
  52. package/dist/utils/file-system.js +21 -1
  53. package/package.json +1 -1
  54. package/scripts/postinstall.js +8 -72
package/README.md CHANGED
@@ -36,27 +36,20 @@ Our philosophy:
36
36
  > [!TIP]
37
37
  > **New workflow now available!** We've rebuilt OpenSpec with a new artifact-guided workflow.
38
38
  >
39
- > Run `/opsx:onboard` to get started. → [Learn more here](docs/opsx.md)
39
+ > Run `/opsx:propose "your idea"` to get started. → [Learn more here](docs/opsx.md)
40
40
 
41
41
  <p align="center">
42
42
  Follow <a href="https://x.com/0xTab">@0xTab on X</a> for updates · Join the <a href="https://discord.gg/YctCnvvshC">OpenSpec Discord</a> for help and questions.
43
43
  </p>
44
44
 
45
- ### Teams
46
-
47
- Using OpenSpec in a team? [Email here](mailto:teams@openspec.dev) for access to our Slack channel.
48
-
49
- <!-- TODO: Add GIF demo of /opsx:new → /opsx:archive workflow -->
45
+ <!-- TODO: Add GIF demo of /opsx:propose → /opsx:archive workflow -->
50
46
 
51
47
  ## See it in action
52
48
 
53
49
  ```text
54
- You: /opsx:new add-dark-mode
50
+ You: /opsx:propose add-dark-mode
55
51
  AI: Created openspec/changes/add-dark-mode/
56
- Ready to create: proposal
57
-
58
- You: /opsx:ff # "fast-forward" - generate all planning docs
59
- AI: ✓ proposal.md — why we're doing this, what's changing
52
+ ✓ proposal.md — why we're doing this, what's changing
60
53
  ✓ specs/ — requirements and scenarios
61
54
  ✓ design.md — technical approach
62
55
  ✓ tasks.md — implementation checklist
@@ -101,10 +94,12 @@ cd your-project
101
94
  openspec init
102
95
  ```
103
96
 
104
- Now tell your AI: `/opsx:new <what-you-want-to-build>`
97
+ Now tell your AI: `/opsx:propose <what-you-want-to-build>`
98
+
99
+ If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:sync`, `/opsx:bulk-archive`, `/opsx:onboard`), select it with `openspec config profile` and apply with `openspec update`.
105
100
 
106
101
  > [!NOTE]
107
- > Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 20+ tools and growing.
102
+ > Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 25+ tools and growing.
108
103
  >
109
104
  > Also works with pnpm, yarn, bun, and nix. [See installation options](docs/installation.md).
110
105
 
@@ -7,13 +7,13 @@
7
7
  import ora from 'ora';
8
8
  import path from 'path';
9
9
  import * as fs from 'fs';
10
- import { loadChangeContext, generateInstructions, resolveSchema, } from '../../core/artifact-graph/index.js';
10
+ import { loadChangeContext, generateInstructions, resolveSchema, resolveArtifactOutputs, } from '../../core/artifact-graph/index.js';
11
11
  import { validateChangeExists, validateSchemaExists, } from './shared.js';
12
12
  // -----------------------------------------------------------------------------
13
13
  // Artifact Instructions Command
14
14
  // -----------------------------------------------------------------------------
15
15
  export async function instructionsCommand(artifactId, options) {
16
- const spinner = ora('Generating instructions...').start();
16
+ const spinner = options.json ? undefined : ora('Generating instructions...').start();
17
17
  try {
18
18
  const projectRoot = process.cwd();
19
19
  const changeName = await validateChangeExists(options.change, projectRoot);
@@ -24,19 +24,19 @@ export async function instructionsCommand(artifactId, options) {
24
24
  // loadChangeContext will auto-detect schema from metadata if not provided
25
25
  const context = loadChangeContext(projectRoot, changeName, options.schema);
26
26
  if (!artifactId) {
27
- spinner.stop();
27
+ spinner?.stop();
28
28
  const validIds = context.graph.getAllArtifacts().map((a) => a.id);
29
29
  throw new Error(`Missing required argument <artifact>. Valid artifacts:\n ${validIds.join('\n ')}`);
30
30
  }
31
31
  const artifact = context.graph.getArtifact(artifactId);
32
32
  if (!artifact) {
33
- spinner.stop();
33
+ spinner?.stop();
34
34
  const validIds = context.graph.getAllArtifacts().map((a) => a.id);
35
35
  throw new Error(`Artifact '${artifactId}' not found in schema '${context.schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`);
36
36
  }
37
37
  const instructions = generateInstructions(context, artifactId, projectRoot);
38
38
  const isBlocked = instructions.dependencies.some((d) => !d.done);
39
- spinner.stop();
39
+ spinner?.stop();
40
40
  if (options.json) {
41
41
  console.log(JSON.stringify(instructions, null, 2));
42
42
  return;
@@ -44,7 +44,7 @@ export async function instructionsCommand(artifactId, options) {
44
44
  printInstructionsText(instructions, isBlocked);
45
45
  }
46
46
  catch (error) {
47
- spinner.stop();
47
+ spinner?.stop();
48
48
  throw error;
49
49
  }
50
50
  }
@@ -161,63 +161,6 @@ function parseTasksFile(content) {
161
161
  }
162
162
  return tasks;
163
163
  }
164
- /**
165
- * Checks if an artifact output exists in the change directory.
166
- * Supports glob patterns (e.g., "specs/*.md") by verifying at least one matching file exists.
167
- */
168
- function artifactOutputExists(changeDir, generates) {
169
- // Normalize the generates path to use platform-specific separators
170
- const normalizedGenerates = generates.split('/').join(path.sep);
171
- const fullPath = path.join(changeDir, normalizedGenerates);
172
- // If it's a glob pattern (contains ** or *), check for matching files
173
- if (generates.includes('*')) {
174
- // Extract the directory part before the glob pattern
175
- const parts = normalizedGenerates.split(path.sep);
176
- const dirParts = [];
177
- let patternPart = '';
178
- for (const part of parts) {
179
- if (part.includes('*')) {
180
- patternPart = part;
181
- break;
182
- }
183
- dirParts.push(part);
184
- }
185
- const dirPath = path.join(changeDir, ...dirParts);
186
- // Check if directory exists
187
- if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
188
- return false;
189
- }
190
- // Extract expected extension from pattern (e.g., "*.md" -> ".md")
191
- const extMatch = patternPart.match(/\*(\.[a-zA-Z0-9]+)$/);
192
- const expectedExt = extMatch ? extMatch[1] : null;
193
- // Recursively check for matching files
194
- const hasMatchingFiles = (dir) => {
195
- try {
196
- const entries = fs.readdirSync(dir, { withFileTypes: true });
197
- for (const entry of entries) {
198
- if (entry.isDirectory()) {
199
- // For ** patterns, recurse into subdirectories
200
- if (generates.includes('**') && hasMatchingFiles(path.join(dir, entry.name))) {
201
- return true;
202
- }
203
- }
204
- else if (entry.isFile()) {
205
- // Check if file matches expected extension (or any file if no extension specified)
206
- if (!expectedExt || entry.name.endsWith(expectedExt)) {
207
- return true;
208
- }
209
- }
210
- }
211
- }
212
- catch {
213
- return false;
214
- }
215
- return false;
216
- };
217
- return hasMatchingFiles(dirPath);
218
- }
219
- return fs.existsSync(fullPath);
220
- }
221
164
  /**
222
165
  * Generates apply instructions for implementing tasks from a change.
223
166
  * Schema-aware: reads apply phase configuration from schema to determine
@@ -226,7 +169,7 @@ function artifactOutputExists(changeDir, generates) {
226
169
  export async function generateApplyInstructions(projectRoot, changeName, schemaName) {
227
170
  // loadChangeContext will auto-detect schema from metadata if not provided
228
171
  const context = loadChangeContext(projectRoot, changeName, schemaName);
229
- const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
172
+ const changeDir = context.changeDir;
230
173
  // Get the full schema to access the apply phase configuration
231
174
  const schema = resolveSchema(context.schemaName, projectRoot);
232
175
  const applyConfig = schema.apply;
@@ -239,15 +182,16 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
239
182
  const missingArtifacts = [];
240
183
  for (const artifactId of requiredArtifactIds) {
241
184
  const artifact = schema.artifacts.find((a) => a.id === artifactId);
242
- if (artifact && !artifactOutputExists(changeDir, artifact.generates)) {
185
+ if (artifact && resolveArtifactOutputs(changeDir, artifact.generates).length === 0) {
243
186
  missingArtifacts.push(artifactId);
244
187
  }
245
188
  }
246
189
  // Build context files from all existing artifacts in schema
247
190
  const contextFiles = {};
248
191
  for (const artifact of schema.artifacts) {
249
- if (artifactOutputExists(changeDir, artifact.generates)) {
250
- contextFiles[artifact.id] = path.join(changeDir, artifact.generates);
192
+ const outputs = resolveArtifactOutputs(changeDir, artifact.generates);
193
+ if (outputs.length > 0) {
194
+ contextFiles[artifact.id] = outputs;
251
195
  }
252
196
  }
253
197
  // Parse tasks if tracking file exists
@@ -310,7 +254,7 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
310
254
  };
311
255
  }
312
256
  export async function applyInstructionsCommand(options) {
313
- const spinner = ora('Generating apply instructions...').start();
257
+ const spinner = options.json ? undefined : ora('Generating apply instructions...').start();
314
258
  try {
315
259
  const projectRoot = process.cwd();
316
260
  const changeName = await validateChangeExists(options.change, projectRoot);
@@ -320,7 +264,7 @@ export async function applyInstructionsCommand(options) {
320
264
  }
321
265
  // generateApplyInstructions uses loadChangeContext which auto-detects schema
322
266
  const instructions = await generateApplyInstructions(projectRoot, changeName, options.schema);
323
- spinner.stop();
267
+ spinner?.stop();
324
268
  if (options.json) {
325
269
  console.log(JSON.stringify(instructions, null, 2));
326
270
  return;
@@ -328,7 +272,7 @@ export async function applyInstructionsCommand(options) {
328
272
  printApplyInstructionsText(instructions);
329
273
  }
330
274
  catch (error) {
331
- spinner.stop();
275
+ spinner?.stop();
332
276
  throw error;
333
277
  }
334
278
  }
@@ -349,8 +293,10 @@ export function printApplyInstructionsText(instructions) {
349
293
  const contextFileEntries = Object.entries(contextFiles);
350
294
  if (contextFileEntries.length > 0) {
351
295
  console.log('### Context Files');
352
- for (const [artifactId, filePath] of contextFileEntries) {
353
- console.log(`- ${artifactId}: ${filePath}`);
296
+ for (const [artifactId, filePaths] of contextFileEntries) {
297
+ for (const filePath of filePaths) {
298
+ console.log(`- ${artifactId}: ${filePath}`);
299
+ }
354
300
  }
355
301
  console.log();
356
302
  }
@@ -13,7 +13,7 @@ export interface ApplyInstructions {
13
13
  changeName: string;
14
14
  changeDir: string;
15
15
  schemaName: string;
16
- contextFiles: Record<string, string>;
16
+ contextFiles: Record<string, string[]>;
17
17
  progress: {
18
18
  total: number;
19
19
  complete: number;
@@ -37,6 +37,11 @@ export declare function getStatusColor(status: 'done' | 'ready' | 'blocked'): (t
37
37
  * Gets the status indicator for an artifact.
38
38
  */
39
39
  export declare function getStatusIndicator(status: 'done' | 'ready' | 'blocked'): string;
40
+ /**
41
+ * Returns the list of available change directory names under openspec/changes/.
42
+ * Excludes the archive directory and hidden directories.
43
+ */
44
+ export declare function getAvailableChanges(projectRoot: string): Promise<string[]>;
40
45
  /**
41
46
  * Validates that a change exists and returns available changes if not.
42
47
  * Checks directory existence directly to support scaffolded changes (without proposal.md).
@@ -52,26 +52,31 @@ export function getStatusIndicator(status) {
52
52
  return color('[-]');
53
53
  }
54
54
  }
55
+ /**
56
+ * Returns the list of available change directory names under openspec/changes/.
57
+ * Excludes the archive directory and hidden directories.
58
+ */
59
+ export async function getAvailableChanges(projectRoot) {
60
+ const changesPath = path.join(projectRoot, 'openspec', 'changes');
61
+ try {
62
+ const entries = await fs.promises.readdir(changesPath, { withFileTypes: true });
63
+ return entries
64
+ .filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))
65
+ .map((e) => e.name);
66
+ }
67
+ catch (error) {
68
+ if (error.code === 'ENOENT')
69
+ return [];
70
+ throw error;
71
+ }
72
+ }
55
73
  /**
56
74
  * Validates that a change exists and returns available changes if not.
57
75
  * Checks directory existence directly to support scaffolded changes (without proposal.md).
58
76
  */
59
77
  export async function validateChangeExists(changeName, projectRoot) {
60
- const changesPath = path.join(projectRoot, 'openspec', 'changes');
61
- // Get all change directories (not just those with proposal.md)
62
- const getAvailableChanges = async () => {
63
- try {
64
- const entries = await fs.promises.readdir(changesPath, { withFileTypes: true });
65
- return entries
66
- .filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))
67
- .map((e) => e.name);
68
- }
69
- catch {
70
- return [];
71
- }
72
- };
73
78
  if (!changeName) {
74
- const available = await getAvailableChanges();
79
+ const available = await getAvailableChanges(projectRoot);
75
80
  if (available.length === 0) {
76
81
  throw new Error('No changes found. Create one with: openspec new change <name>');
77
82
  }
@@ -83,10 +88,10 @@ export async function validateChangeExists(changeName, projectRoot) {
83
88
  throw new Error(`Invalid change name '${changeName}': ${nameValidation.error}`);
84
89
  }
85
90
  // Check directory existence directly
86
- const changePath = path.join(changesPath, changeName);
91
+ const changePath = path.join(projectRoot, 'openspec', 'changes', changeName);
87
92
  const exists = fs.existsSync(changePath) && fs.statSync(changePath).isDirectory();
88
93
  if (!exists) {
89
- const available = await getAvailableChanges();
94
+ const available = await getAvailableChanges(projectRoot);
90
95
  if (available.length === 0) {
91
96
  throw new Error(`Change '${changeName}' not found. No changes exist. Create one with: openspec new change <name>`);
92
97
  }
@@ -6,14 +6,31 @@
6
6
  import ora from 'ora';
7
7
  import chalk from 'chalk';
8
8
  import { loadChangeContext, formatChangeStatus, } from '../../core/artifact-graph/index.js';
9
- import { validateChangeExists, validateSchemaExists, getStatusIndicator, getStatusColor, } from './shared.js';
9
+ import { validateChangeExists, validateSchemaExists, getAvailableChanges, getStatusIndicator, getStatusColor, } from './shared.js';
10
10
  // -----------------------------------------------------------------------------
11
11
  // Command Implementation
12
12
  // -----------------------------------------------------------------------------
13
13
  export async function statusCommand(options) {
14
- const spinner = ora('Loading change status...').start();
14
+ const spinner = options.json ? undefined : ora('Loading change status...').start();
15
15
  try {
16
16
  const projectRoot = process.cwd();
17
+ // Handle no-changes case gracefully — status is informational,
18
+ // so "no changes" is a valid state, not an error.
19
+ if (!options.change) {
20
+ const available = await getAvailableChanges(projectRoot);
21
+ if (available.length === 0) {
22
+ spinner?.stop();
23
+ if (options.json) {
24
+ console.log(JSON.stringify({ changes: [], message: 'No active changes.' }, null, 2));
25
+ return;
26
+ }
27
+ console.log('No active changes. Create one with: openspec new change <name>');
28
+ return;
29
+ }
30
+ // Changes exist but --change not provided
31
+ spinner?.stop();
32
+ throw new Error(`Missing required option --change. Available changes:\n ${available.join('\n ')}`);
33
+ }
17
34
  const changeName = await validateChangeExists(options.change, projectRoot);
18
35
  // Validate schema if explicitly provided
19
36
  if (options.schema) {
@@ -22,7 +39,7 @@ export async function statusCommand(options) {
22
39
  // loadChangeContext will auto-detect schema from metadata if not provided
23
40
  const context = loadChangeContext(projectRoot, changeName, options.schema);
24
41
  const status = formatChangeStatus(context);
25
- spinner.stop();
42
+ spinner?.stop();
26
43
  if (options.json) {
27
44
  console.log(JSON.stringify(status, null, 2));
28
45
  return;
@@ -30,7 +47,7 @@ export async function statusCommand(options) {
30
47
  printStatusText(status);
31
48
  }
32
49
  catch (error) {
33
- spinner.stop();
50
+ spinner?.stop();
34
51
  throw error;
35
52
  }
36
53
  }
@@ -6,12 +6,13 @@
6
6
  import ora from 'ora';
7
7
  import path from 'path';
8
8
  import { resolveSchema, getSchemaDir, ArtifactGraph, } from '../../core/artifact-graph/index.js';
9
+ import { FileSystemUtils } from '../../utils/file-system.js';
9
10
  import { validateSchemaExists, DEFAULT_SCHEMA } from './shared.js';
10
11
  // -----------------------------------------------------------------------------
11
12
  // Command Implementation
12
13
  // -----------------------------------------------------------------------------
13
14
  export async function templatesCommand(options) {
14
- const spinner = ora('Loading templates...').start();
15
+ const spinner = options.json ? undefined : ora('Loading templates...').start();
15
16
  try {
16
17
  const projectRoot = process.cwd();
17
18
  const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA, projectRoot);
@@ -40,10 +41,10 @@ export async function templatesCommand(options) {
40
41
  }
41
42
  const templates = graph.getAllArtifacts().map((artifact) => ({
42
43
  artifactId: artifact.id,
43
- templatePath: path.join(schemaDir, 'templates', artifact.template),
44
+ templatePath: FileSystemUtils.canonicalizeExistingPath(path.join(schemaDir, 'templates', artifact.template)),
44
45
  source,
45
46
  }));
46
- spinner.stop();
47
+ spinner?.stop();
47
48
  if (options.json) {
48
49
  const output = {};
49
50
  for (const t of templates) {
@@ -61,7 +62,7 @@ export async function templatesCommand(options) {
61
62
  }
62
63
  }
63
64
  catch (error) {
64
- spinner.stop();
65
+ spinner?.stop();
65
66
  throw error;
66
67
  }
67
68
  }
@@ -2,6 +2,7 @@ export { ArtifactSchema, SchemaYamlSchema, type Artifact, type SchemaYaml, type
2
2
  export { loadSchema, parseSchema, SchemaValidationError } from './schema.js';
3
3
  export { ArtifactGraph } from './graph.js';
4
4
  export { detectCompleted } from './state.js';
5
+ export { artifactOutputExists, isGlobPattern, resolveArtifactOutputs } from './outputs.js';
5
6
  export { resolveSchema, listSchemas, listSchemasWithInfo, getSchemaDir, getPackageSchemasDir, getUserSchemasDir, SchemaLoadError, type SchemaInfo, } from './resolver.js';
6
7
  export { loadTemplate, loadChangeContext, generateInstructions, formatChangeStatus, TemplateLoadError, type ChangeContext, type ArtifactInstructions, type DependencyInfo, type ArtifactStatus, type ChangeStatus, } from './instruction-loader.js';
7
8
  //# sourceMappingURL=index.d.ts.map
@@ -6,6 +6,7 @@ export { loadSchema, parseSchema, SchemaValidationError } from './schema.js';
6
6
  export { ArtifactGraph } from './graph.js';
7
7
  // State detection
8
8
  export { detectCompleted } from './state.js';
9
+ export { artifactOutputExists, isGlobPattern, resolveArtifactOutputs } from './outputs.js';
9
10
  // Schema resolution
10
11
  export { resolveSchema, listSchemas, listSchemasWithInfo, getSchemaDir, getPackageSchemasDir, getUserSchemasDir, SchemaLoadError, } from './resolver.js';
11
12
  // Instruction loading
@@ -4,6 +4,7 @@ import { getSchemaDir, resolveSchema } from './resolver.js';
4
4
  import { ArtifactGraph } from './graph.js';
5
5
  import { detectCompleted } from './state.js';
6
6
  import { resolveSchemaForChange } from '../../utils/change-metadata.js';
7
+ import { FileSystemUtils } from '../../utils/file-system.js';
7
8
  import { readProjectConfig, validateConfigRules } from '../project-config.js';
8
9
  // Session-level cache for validation warnings (avoid repeating same warnings)
9
10
  const shownWarnings = new Set();
@@ -32,10 +33,11 @@ export function loadTemplate(schemaName, templatePath, projectRoot) {
32
33
  if (!schemaDir) {
33
34
  throw new TemplateLoadError(`Schema '${schemaName}' not found`, templatePath);
34
35
  }
35
- const fullPath = path.join(schemaDir, 'templates', templatePath);
36
- if (!fs.existsSync(fullPath)) {
37
- throw new TemplateLoadError(`Template not found: ${fullPath}`, fullPath);
36
+ const templatePathOnDisk = path.join(schemaDir, 'templates', templatePath);
37
+ if (!fs.existsSync(templatePathOnDisk)) {
38
+ throw new TemplateLoadError(`Template not found: ${templatePathOnDisk}`, templatePathOnDisk);
38
39
  }
40
+ const fullPath = FileSystemUtils.canonicalizeExistingPath(templatePathOnDisk);
39
41
  try {
40
42
  return fs.readFileSync(fullPath, 'utf-8');
41
43
  }
@@ -58,7 +60,7 @@ export function loadTemplate(schemaName, templatePath, projectRoot) {
58
60
  * @returns Change context with graph, completed set, and metadata
59
61
  */
60
62
  export function loadChangeContext(projectRoot, changeName, schemaName) {
61
- const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
63
+ const changeDir = FileSystemUtils.canonicalizeExistingPath(path.join(projectRoot, 'openspec', 'changes', changeName));
62
64
  // Resolve schema: explicit > metadata > default
63
65
  const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName);
64
66
  const schema = resolveSchema(resolvedSchemaName, projectRoot);
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Checks if a path contains glob pattern characters.
3
+ */
4
+ export declare function isGlobPattern(pattern: string): boolean;
5
+ /**
6
+ * Resolves an artifact's output path(s) to concrete files that currently exist.
7
+ * Returns absolute file paths. Glob matches are sorted for deterministic output.
8
+ */
9
+ export declare function resolveArtifactOutputs(changeDir: string, generates: string): string[];
10
+ /**
11
+ * Checks if an artifact has at least one resolved output file.
12
+ */
13
+ export declare function artifactOutputExists(changeDir: string, generates: string): boolean;
14
+ //# sourceMappingURL=outputs.d.ts.map
@@ -0,0 +1,39 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import fg from 'fast-glob';
4
+ import { FileSystemUtils } from '../../utils/file-system.js';
5
+ /**
6
+ * Checks if a path contains glob pattern characters.
7
+ */
8
+ export function isGlobPattern(pattern) {
9
+ return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
10
+ }
11
+ /**
12
+ * Resolves an artifact's output path(s) to concrete files that currently exist.
13
+ * Returns absolute file paths. Glob matches are sorted for deterministic output.
14
+ */
15
+ export function resolveArtifactOutputs(changeDir, generates) {
16
+ if (!isGlobPattern(generates)) {
17
+ const fullPath = path.join(changeDir, generates);
18
+ try {
19
+ return fs.statSync(fullPath).isFile()
20
+ ? [FileSystemUtils.canonicalizeExistingPath(fullPath)]
21
+ : [];
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ }
27
+ const normalizedPattern = FileSystemUtils.toPosixPath(generates);
28
+ const matches = fg
29
+ .sync(normalizedPattern, { cwd: changeDir, onlyFiles: true, absolute: true })
30
+ .map((match) => FileSystemUtils.canonicalizeExistingPath(path.normalize(match)));
31
+ return Array.from(new Set(matches)).sort();
32
+ }
33
+ /**
34
+ * Checks if an artifact has at least one resolved output file.
35
+ */
36
+ export function artifactOutputExists(changeDir, generates) {
37
+ return resolveArtifactOutputs(changeDir, generates).length > 0;
38
+ }
39
+ //# sourceMappingURL=outputs.js.map
@@ -1,7 +1,5 @@
1
1
  import * as fs from 'node:fs';
2
- import * as path from 'node:path';
3
- import fg from 'fast-glob';
4
- import { FileSystemUtils } from '../../utils/file-system.js';
2
+ import { artifactOutputExists } from './outputs.js';
5
3
  /**
6
4
  * Detects which artifacts are completed by checking file existence in the change directory.
7
5
  * Returns a Set of completed artifact IDs.
@@ -28,27 +26,6 @@ export function detectCompleted(graph, changeDir) {
28
26
  * Supports both simple paths and glob patterns.
29
27
  */
30
28
  function isArtifactComplete(generates, changeDir) {
31
- const fullPattern = path.join(changeDir, generates);
32
- // Check if it's a glob pattern
33
- if (isGlobPattern(generates)) {
34
- return hasGlobMatches(fullPattern);
35
- }
36
- // Simple file path - check if file exists
37
- return fs.existsSync(fullPattern);
38
- }
39
- /**
40
- * Checks if a path contains glob pattern characters.
41
- */
42
- function isGlobPattern(pattern) {
43
- return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
44
- }
45
- /**
46
- * Checks if a glob pattern has any matches.
47
- * Normalizes Windows backslashes to forward slashes for cross-platform glob compatibility.
48
- */
49
- function hasGlobMatches(pattern) {
50
- const normalizedPattern = FileSystemUtils.toPosixPath(pattern);
51
- const matches = fg.sync(normalizedPattern, { onlyFiles: true });
52
- return matches.length > 0;
29
+ return artifactOutputExists(changeDir, generates);
53
30
  }
54
31
  //# sourceMappingURL=state.js.map
@@ -9,8 +9,9 @@ import { type AIToolOption } from './config.js';
9
9
  * Scans the project path for AI tool configuration directories and returns
10
10
  * the tools that are present.
11
11
  *
12
- * Checks for each tool's `skillsDir` (e.g., `.claude/`, `.cursor/`) at the
13
- * project root. Only tools with a `skillsDir` property are considered.
12
+ * For tools with `detectionPaths`, checks those specific paths (files or
13
+ * directories). Otherwise checks for the tool's `skillsDir` directory at
14
+ * the project root. Only tools with a `skillsDir` property are considered.
14
15
  */
15
16
  export declare function getAvailableTools(projectPath: string): AIToolOption[];
16
17
  //# sourceMappingURL=available-tools.d.ts.map
@@ -11,13 +11,26 @@ import { AI_TOOLS } from './config.js';
11
11
  * Scans the project path for AI tool configuration directories and returns
12
12
  * the tools that are present.
13
13
  *
14
- * Checks for each tool's `skillsDir` (e.g., `.claude/`, `.cursor/`) at the
15
- * project root. Only tools with a `skillsDir` property are considered.
14
+ * For tools with `detectionPaths`, checks those specific paths (files or
15
+ * directories). Otherwise checks for the tool's `skillsDir` directory at
16
+ * the project root. Only tools with a `skillsDir` property are considered.
16
17
  */
17
18
  export function getAvailableTools(projectPath) {
18
19
  return AI_TOOLS.filter((tool) => {
19
20
  if (!tool.skillsDir)
20
21
  return false;
22
+ if (tool.detectionPaths && tool.detectionPaths.length > 0) {
23
+ // statSync without .isDirectory() — detection paths can be files or directories
24
+ return tool.detectionPaths.some((p) => {
25
+ try {
26
+ fs.statSync(path.join(projectPath, p));
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ });
33
+ }
21
34
  const dirPath = path.join(projectPath, tool.skillsDir);
22
35
  try {
23
36
  return fs.statSync(dirPath).isDirectory();
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Bob Shell Command Adapter
3
+ *
4
+ * Formats commands for Bob Shell following its markdown specification.
5
+ * Commands are stored in .bob/commands/ directory.
6
+ */
7
+ import type { ToolCommandAdapter } from '../types.js';
8
+ /**
9
+ * Bob Shell adapter for command generation.
10
+ * File path: .bob/commands/opsx-<id>.md
11
+ * Frontmatter: description, argument-hint
12
+ */
13
+ export declare const bobAdapter: ToolCommandAdapter;
14
+ //# sourceMappingURL=bob.d.ts.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Bob Shell Command Adapter
3
+ *
4
+ * Formats commands for Bob Shell following its markdown specification.
5
+ * Commands are stored in .bob/commands/ directory.
6
+ */
7
+ import path from 'path';
8
+ import { transformToHyphenCommands } from '../../../utils/command-references.js';
9
+ /**
10
+ * Escapes a string value for safe YAML output.
11
+ * Quotes the string if it contains special YAML characters.
12
+ */
13
+ function escapeYamlValue(value) {
14
+ // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace)
15
+ const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value);
16
+ if (needsQuoting) {
17
+ // Use double quotes and escape internal double quotes and backslashes
18
+ const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
19
+ return `"${escaped}"`;
20
+ }
21
+ return value;
22
+ }
23
+ /**
24
+ * Bob Shell adapter for command generation.
25
+ * File path: .bob/commands/opsx-<id>.md
26
+ * Frontmatter: description, argument-hint
27
+ */
28
+ export const bobAdapter = {
29
+ toolId: 'bob',
30
+ getFilePath(commandId) {
31
+ return path.join('.bob', 'commands', `opsx-${commandId}.md`);
32
+ },
33
+ formatFile(content) {
34
+ // Transform command references from colon to hyphen format for Bob
35
+ const transformedBody = transformToHyphenCommands(content.body);
36
+ return `---
37
+ description: ${escapeYamlValue(content.description)}
38
+ argument-hint: command arguments
39
+ ---
40
+
41
+ ${transformedBody}
42
+ `;
43
+ },
44
+ };
45
+ //# sourceMappingURL=bob.js.map