@fission-ai/openspec 1.3.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.
package/README.md CHANGED
@@ -42,10 +42,6 @@ Our philosophy:
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
45
  <!-- TODO: Add GIF demo of /opsx:propose → /opsx:archive workflow -->
50
46
 
51
47
  ## See it in action
@@ -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;
@@ -11,7 +11,7 @@ import { validateChangeExists, validateSchemaExists, getAvailableChanges, getSta
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
17
  // Handle no-changes case gracefully — status is informational,
@@ -19,7 +19,7 @@ export async function statusCommand(options) {
19
19
  if (!options.change) {
20
20
  const available = await getAvailableChanges(projectRoot);
21
21
  if (available.length === 0) {
22
- spinner.stop();
22
+ spinner?.stop();
23
23
  if (options.json) {
24
24
  console.log(JSON.stringify({ changes: [], message: 'No active changes.' }, null, 2));
25
25
  return;
@@ -28,7 +28,7 @@ export async function statusCommand(options) {
28
28
  return;
29
29
  }
30
30
  // Changes exist but --change not provided
31
- spinner.stop();
31
+ spinner?.stop();
32
32
  throw new Error(`Missing required option --change. Available changes:\n ${available.join('\n ')}`);
33
33
  }
34
34
  const changeName = await validateChangeExists(options.change, projectRoot);
@@ -39,7 +39,7 @@ export async function statusCommand(options) {
39
39
  // loadChangeContext will auto-detect schema from metadata if not provided
40
40
  const context = loadChangeContext(projectRoot, changeName, options.schema);
41
41
  const status = formatChangeStatus(context);
42
- spinner.stop();
42
+ spinner?.stop();
43
43
  if (options.json) {
44
44
  console.log(JSON.stringify(status, null, 2));
45
45
  return;
@@ -47,7 +47,7 @@ export async function statusCommand(options) {
47
47
  printStatusText(status);
48
48
  }
49
49
  catch (error) {
50
- spinner.stop();
50
+ spinner?.stop();
51
51
  throw error;
52
52
  }
53
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
@@ -148,15 +148,19 @@ export class ChangeParser extends MarkdownParser {
148
148
  parseSectionsFromContent(content) {
149
149
  const normalizedContent = ChangeParser.normalizeContent(content);
150
150
  const lines = normalizedContent.split('\n');
151
+ const codeFenceLineMask = ChangeParser.buildCodeFenceMask(lines);
151
152
  const sections = [];
152
153
  const stack = [];
153
154
  for (let i = 0; i < lines.length; i++) {
154
155
  const line = lines[i];
156
+ if (codeFenceLineMask[i]) {
157
+ continue;
158
+ }
155
159
  const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
156
160
  if (headerMatch) {
157
161
  const level = headerMatch[1].length;
158
162
  const title = headerMatch[2].trim();
159
- const contentLines = this.getContentUntilNextHeaderFromLines(lines, i + 1, level);
163
+ const contentLines = this.getContentUntilNextHeaderFromLines(lines, codeFenceLineMask, i + 1, level);
160
164
  const section = {
161
165
  level,
162
166
  title,
@@ -177,11 +181,11 @@ export class ChangeParser extends MarkdownParser {
177
181
  }
178
182
  return sections;
179
183
  }
180
- getContentUntilNextHeaderFromLines(lines, startLine, currentLevel) {
184
+ getContentUntilNextHeaderFromLines(lines, codeFenceLineMask, startLine, currentLevel) {
181
185
  const contentLines = [];
182
186
  for (let i = startLine; i < lines.length; i++) {
183
187
  const line = lines[i];
184
- const headerMatch = line.match(/^(#{1,6})\s+/);
188
+ const headerMatch = codeFenceLineMask[i] ? null : line.match(/^(#{1,6})\s+/);
185
189
  if (headerMatch && headerMatch[1].length <= currentLevel) {
186
190
  break;
187
191
  }
@@ -7,9 +7,13 @@ export interface Section {
7
7
  }
8
8
  export declare class MarkdownParser {
9
9
  private lines;
10
+ private codeFenceLineMask;
10
11
  private currentLine;
11
12
  constructor(content: string);
12
13
  protected static normalizeContent(content: string): string;
14
+ protected static buildCodeFenceMask(lines: string[]): boolean[];
15
+ private static getFenceMarker;
16
+ private static isClosingFence;
13
17
  parseSpec(name: string): Spec;
14
18
  parseChange(name: string): Change;
15
19
  protected parseSections(): Section[];
@@ -1,14 +1,51 @@
1
1
  export class MarkdownParser {
2
2
  lines;
3
+ codeFenceLineMask;
3
4
  currentLine;
4
5
  constructor(content) {
5
6
  const normalized = MarkdownParser.normalizeContent(content);
6
7
  this.lines = normalized.split('\n');
8
+ this.codeFenceLineMask = MarkdownParser.buildCodeFenceMask(this.lines);
7
9
  this.currentLine = 0;
8
10
  }
9
11
  static normalizeContent(content) {
10
12
  return content.replace(/\r\n?/g, '\n');
11
13
  }
14
+ static buildCodeFenceMask(lines) {
15
+ const mask = new Array(lines.length).fill(false);
16
+ let activeFence = null;
17
+ for (let i = 0; i < lines.length; i++) {
18
+ const fence = MarkdownParser.getFenceMarker(lines[i]);
19
+ if (!activeFence) {
20
+ if (fence) {
21
+ activeFence = fence;
22
+ mask[i] = true;
23
+ }
24
+ continue;
25
+ }
26
+ mask[i] = true;
27
+ if (MarkdownParser.isClosingFence(lines[i], activeFence)) {
28
+ activeFence = null;
29
+ }
30
+ }
31
+ return mask;
32
+ }
33
+ static getFenceMarker(line) {
34
+ const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
35
+ if (!fenceMatch) {
36
+ return null;
37
+ }
38
+ return {
39
+ marker: fenceMatch[1][0],
40
+ length: fenceMatch[1].length,
41
+ };
42
+ }
43
+ static isClosingFence(line, activeFence) {
44
+ const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/);
45
+ return Boolean(fenceMatch &&
46
+ fenceMatch[1][0] === activeFence.marker &&
47
+ fenceMatch[1].length >= activeFence.length);
48
+ }
12
49
  parseSpec(name) {
13
50
  const sections = this.parseSections();
14
51
  const purpose = this.findSection(sections, 'Purpose')?.content || '';
@@ -57,6 +94,9 @@ export class MarkdownParser {
57
94
  const stack = [];
58
95
  for (let i = 0; i < this.lines.length; i++) {
59
96
  const line = this.lines[i];
97
+ if (this.codeFenceLineMask[i]) {
98
+ continue;
99
+ }
60
100
  const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
61
101
  if (headerMatch) {
62
102
  const level = headerMatch[1].length;
@@ -86,7 +126,7 @@ export class MarkdownParser {
86
126
  const contentLines = [];
87
127
  for (let i = startLine; i < this.lines.length; i++) {
88
128
  const line = this.lines[i];
89
- const headerMatch = line.match(/^(#{1,6})\s+/);
129
+ const headerMatch = this.codeFenceLineMask[i] ? null : line.match(/^(#{1,6})\s+/);
90
130
  if (headerMatch && headerMatch[1].length <= currentLevel) {
91
131
  break;
92
132
  }
@@ -0,0 +1,9 @@
1
+ export interface MainSpecStructureIssue {
2
+ kind: 'delta-header' | 'requirement-outside-requirements';
3
+ line: number;
4
+ header: string;
5
+ message: string;
6
+ }
7
+ export declare function findMainSpecStructureIssues(content: string): MainSpecStructureIssue[];
8
+ export declare function stripFencedCodeBlocksPreservingLines(content: string): string;
9
+ //# sourceMappingURL=spec-structure.d.ts.map
@@ -0,0 +1,88 @@
1
+ const REQUIREMENTS_SECTION_HEADER = /^##\s+Requirements\s*$/i;
2
+ const TOP_LEVEL_SECTION_HEADER = /^##\s+/;
3
+ const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i;
4
+ const REQUIREMENT_HEADER = /^###\s+Requirement:\s*(.+)\s*$/;
5
+ export function findMainSpecStructureIssues(content) {
6
+ const normalized = content.replace(/\r\n?/g, '\n');
7
+ const stripped = stripFencedCodeBlocksPreservingLines(normalized);
8
+ const lines = stripped.split('\n');
9
+ const issues = [];
10
+ const requirementsHeaderIndex = lines.findIndex(line => REQUIREMENTS_SECTION_HEADER.test(line));
11
+ let requirementsEndIndex = lines.length;
12
+ if (requirementsHeaderIndex !== -1) {
13
+ for (let i = requirementsHeaderIndex + 1; i < lines.length; i++) {
14
+ if (TOP_LEVEL_SECTION_HEADER.test(lines[i])) {
15
+ requirementsEndIndex = i;
16
+ break;
17
+ }
18
+ }
19
+ }
20
+ for (let i = 0; i < lines.length; i++) {
21
+ const line = lines[i];
22
+ const trimmed = line.trim();
23
+ if (!trimmed) {
24
+ continue;
25
+ }
26
+ if (DELTA_HEADER.test(line)) {
27
+ issues.push({
28
+ kind: 'delta-header',
29
+ line: i + 1,
30
+ header: trimmed,
31
+ message: `Main spec contains delta header "${trimmed}". ` +
32
+ 'Delta headers are only valid inside openspec/changes/<name>/specs/<capability>/spec.md ' +
33
+ 'and truncate the parsed ## Requirements section.',
34
+ });
35
+ continue;
36
+ }
37
+ const requirementMatch = line.match(REQUIREMENT_HEADER);
38
+ if (!requirementMatch) {
39
+ continue;
40
+ }
41
+ const insideRequirements = requirementsHeaderIndex !== -1 &&
42
+ i > requirementsHeaderIndex &&
43
+ i < requirementsEndIndex;
44
+ if (!insideRequirements) {
45
+ issues.push({
46
+ kind: 'requirement-outside-requirements',
47
+ line: i + 1,
48
+ header: trimmed,
49
+ message: `Requirement header "${trimmed}" appears outside the main ## Requirements section. ` +
50
+ 'Main specs only parse requirements inside that section, so this requirement is currently invisible to validate, list, and archive.',
51
+ });
52
+ }
53
+ }
54
+ return issues;
55
+ }
56
+ export function stripFencedCodeBlocksPreservingLines(content) {
57
+ const lines = content.split('\n');
58
+ const output = [];
59
+ let activeFence = null;
60
+ for (const line of lines) {
61
+ const fenceMatch = line.match(/^\s*(`{3,}|~{3,})(.*)$/);
62
+ if (!activeFence) {
63
+ if (fenceMatch) {
64
+ activeFence = {
65
+ marker: fenceMatch[1][0],
66
+ length: fenceMatch[1].length,
67
+ };
68
+ output.push('');
69
+ }
70
+ else {
71
+ output.push(line);
72
+ }
73
+ continue;
74
+ }
75
+ output.push('');
76
+ if (isClosingFence(line, activeFence)) {
77
+ activeFence = null;
78
+ }
79
+ }
80
+ return output.join('\n');
81
+ }
82
+ function isClosingFence(line, activeFence) {
83
+ const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/);
84
+ return Boolean(fenceMatch &&
85
+ fenceMatch[1][0] === activeFence.marker &&
86
+ fenceMatch[1].length >= activeFence.length);
87
+ }
88
+ //# sourceMappingURL=spec-structure.js.map
@@ -8,6 +8,7 @@ import { promises as fs } from 'fs';
8
8
  import path from 'path';
9
9
  import chalk from 'chalk';
10
10
  import { extractRequirementsSection, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
11
+ import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
11
12
  import { Validator } from './validation/validator.js';
12
13
  // -----------------------------------------------------------------------------
13
14
  // Public API
@@ -153,6 +154,13 @@ export async function buildUpdatedSpec(update, changeName) {
153
154
  isNewSpec = true;
154
155
  targetContent = buildSpecSkeleton(specName, changeName);
155
156
  }
157
+ const structureIssues = findMainSpecStructureIssues(targetContent);
158
+ if (structureIssues.length > 0) {
159
+ const details = structureIssues
160
+ .map(issue => `line ${issue.line}: ${issue.message}`)
161
+ .join('\n');
162
+ throw new Error(`${specName}: target spec is structurally invalid and cannot be updated until fixed:\n${details}`);
163
+ }
156
164
  // Extract requirements section and build name->block map
157
165
  const parts = extractRequirementsSection(targetContent);
158
166
  const nameToBlock = new Map();
@@ -32,7 +32,7 @@ export function getApplyChangeSkillTemplate() {
32
32
  \`\`\`
33
33
 
34
34
  This returns:
35
- - Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
35
+ - \`contextFiles\`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
36
36
  - Progress (total, complete, remaining)
37
37
  - Task list with status
38
38
  - Dynamic instruction based on current state
@@ -44,7 +44,7 @@ export function getApplyChangeSkillTemplate() {
44
44
 
45
45
  4. **Read context files**
46
46
 
47
- Read the files listed in \`contextFiles\` from the apply instructions output.
47
+ Read every file path listed under \`contextFiles\` from the apply instructions output.
48
48
  The files depend on the schema being used:
49
49
  - **spec-driven**: proposal, specs, design, tasks
50
50
  - Other schemas: follow the contextFiles from CLI output
@@ -188,7 +188,7 @@ export function getOpsxApplyCommandTemplate() {
188
188
  \`\`\`
189
189
 
190
190
  This returns:
191
- - Context file paths (varies by schema)
191
+ - \`contextFiles\`: artifact ID -> array of concrete file paths (varies by schema)
192
192
  - Progress (total, complete, remaining)
193
193
  - Task list with status
194
194
  - Dynamic instruction based on current state
@@ -200,7 +200,7 @@ export function getOpsxApplyCommandTemplate() {
200
200
 
201
201
  4. **Read context files**
202
202
 
203
- Read the files listed in \`contextFiles\` from the apply instructions output.
203
+ Read every file path listed under \`contextFiles\` from the apply instructions output.
204
204
  The files depend on the schema being used:
205
205
  - **spec-driven**: proposal, specs, design, tasks
206
206
  - Other schemas: follow the contextFiles from CLI output
@@ -32,7 +32,7 @@ export function getVerifyChangeSkillTemplate() {
32
32
  openspec instructions apply --change "<name>" --json
33
33
  \`\`\`
34
34
 
35
- This returns the change directory and context files. Read all available artifacts from \`contextFiles\`.
35
+ This returns the change directory and \`contextFiles\` (artifact ID -> array of concrete file paths). Read all available artifacts from \`contextFiles\`.
36
36
 
37
37
  4. **Initialize verification report structure**
38
38
 
@@ -46,7 +46,7 @@ export function getVerifyChangeSkillTemplate() {
46
46
  5. **Verify Completeness**
47
47
 
48
48
  **Task Completion**:
49
- - If tasks.md exists in contextFiles, read it
49
+ - If \`contextFiles.tasks\` exists, read every file path in it
50
50
  - Parse checkboxes: \`- [ ]\` (incomplete) vs \`- [x]\` (complete)
51
51
  - Count complete vs total tasks
52
52
  - If incomplete tasks exist:
@@ -85,7 +85,7 @@ export function getVerifyChangeSkillTemplate() {
85
85
  7. **Verify Coherence**
86
86
 
87
87
  **Design Adherence**:
88
- - If design.md exists in contextFiles:
88
+ - If \`contextFiles.design\` exists:
89
89
  - Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
90
90
  - Verify implementation follows those decisions
91
91
  - If contradiction detected:
@@ -200,7 +200,7 @@ export function getOpsxVerifyCommandTemplate() {
200
200
  openspec instructions apply --change "<name>" --json
201
201
  \`\`\`
202
202
 
203
- This returns the change directory and context files. Read all available artifacts from \`contextFiles\`.
203
+ This returns the change directory and \`contextFiles\` (artifact ID -> array of concrete file paths). Read all available artifacts from \`contextFiles\`.
204
204
 
205
205
  4. **Initialize verification report structure**
206
206
 
@@ -214,7 +214,7 @@ export function getOpsxVerifyCommandTemplate() {
214
214
  5. **Verify Completeness**
215
215
 
216
216
  **Task Completion**:
217
- - If tasks.md exists in contextFiles, read it
217
+ - If \`contextFiles.tasks\` exists, read every file path in it
218
218
  - Parse checkboxes: \`- [ ]\` (incomplete) vs \`- [x]\` (complete)
219
219
  - Count complete vs total tasks
220
220
  - If incomplete tasks exist:
@@ -253,7 +253,7 @@ export function getOpsxVerifyCommandTemplate() {
253
253
  7. **Verify Coherence**
254
254
 
255
255
  **Design Adherence**:
256
- - If design.md exists in contextFiles:
256
+ - If \`contextFiles.design\` exists:
257
257
  - Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
258
258
  - Verify implementation follows those decisions
259
259
  - If contradiction detected:
@@ -5,6 +5,7 @@ import { MarkdownParser } from '../parsers/markdown-parser.js';
5
5
  import { ChangeParser } from '../parsers/change-parser.js';
6
6
  import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
7
7
  import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js';
8
+ import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
8
9
  import { FileSystemUtils } from '../../utils/file-system.js';
9
10
  export class Validator {
10
11
  strictMode;
@@ -270,6 +271,14 @@ export class Validator {
270
271
  }
271
272
  applySpecRules(spec, content) {
272
273
  const issues = [];
274
+ for (const structuralIssue of findMainSpecStructureIssues(content)) {
275
+ issues.push({
276
+ level: 'ERROR',
277
+ path: 'file',
278
+ line: structuralIssue.line,
279
+ message: structuralIssue.message,
280
+ });
281
+ }
273
282
  if (spec.overview.length < MIN_PURPOSE_LENGTH) {
274
283
  issues.push({
275
284
  level: 'WARNING',
@@ -1,3 +1,5 @@
1
+ export declare const CONFIG_DIR_NAME = "openspec";
2
+ export declare const CONFIG_FILE_NAME = "config.json";
1
3
  export interface TelemetryConfig {
2
4
  anonymousId?: string;
3
5
  noticeSeen?: boolean;
@@ -8,7 +10,11 @@ export interface GlobalConfig {
8
10
  }
9
11
  /**
10
12
  * Get the path to the global config file.
11
- * Uses ~/.config/openspec/config.json on all platforms.
13
+ * Follows XDG Base Directory Specification and platform conventions.
14
+ *
15
+ * - All platforms: $XDG_CONFIG_HOME/openspec/ if XDG_CONFIG_HOME is set
16
+ * - Unix/macOS fallback: ~/.config/openspec/
17
+ * - Windows fallback: %APPDATA%/openspec/
12
18
  */
13
19
  export declare function getConfigPath(): string;
14
20
  /**
@@ -1,17 +1,96 @@
1
1
  /**
2
2
  * Global configuration for telemetry state.
3
- * Stores anonymous ID and notice-seen flag in ~/.config/openspec/config.json
3
+ * Stores anonymous ID and notice-seen flag in the platform-appropriate config directory.
4
4
  */
5
5
  import { promises as fs } from 'fs';
6
6
  import path from 'path';
7
7
  import os from 'os';
8
+ import { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, getGlobalConfigDir, } from '../core/global-config.js';
9
+ // Constants
10
+ export const CONFIG_DIR_NAME = GLOBAL_CONFIG_DIR_NAME;
11
+ export const CONFIG_FILE_NAME = GLOBAL_CONFIG_FILE_NAME;
12
+ function getConfigDir() {
13
+ return getGlobalConfigDir();
14
+ }
15
+ function getLegacyConfigPath() {
16
+ return path.join(os.homedir(), '.config', CONFIG_DIR_NAME, CONFIG_FILE_NAME);
17
+ }
18
+ async function readConfigFile(configPath) {
19
+ try {
20
+ const content = await fs.readFile(configPath, 'utf-8');
21
+ return { status: 'ok', config: JSON.parse(content) };
22
+ }
23
+ catch (error) {
24
+ if (error.code === 'ENOENT') {
25
+ return { status: 'missing' };
26
+ }
27
+ // If parse fails or another read error occurs, ignore the file.
28
+ return { status: 'invalid', config: {} };
29
+ }
30
+ }
31
+ async function writeConfigFile(configPath, config) {
32
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
33
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
34
+ }
35
+ function hasMissingTelemetryFields(config) {
36
+ const telemetry = config.telemetry;
37
+ return (!telemetry ||
38
+ telemetry.anonymousId === undefined ||
39
+ telemetry.noticeSeen === undefined);
40
+ }
41
+ function mergeLegacyTelemetry(config, legacyConfig) {
42
+ const legacyTelemetry = legacyConfig.telemetry;
43
+ if (!legacyTelemetry) {
44
+ return undefined;
45
+ }
46
+ const currentTelemetry = config.telemetry ?? {};
47
+ const shouldMigrate = (currentTelemetry.anonymousId === undefined && legacyTelemetry.anonymousId !== undefined) ||
48
+ (currentTelemetry.noticeSeen === undefined && legacyTelemetry.noticeSeen !== undefined);
49
+ if (!shouldMigrate) {
50
+ return undefined;
51
+ }
52
+ return {
53
+ ...config,
54
+ telemetry: {
55
+ ...legacyTelemetry,
56
+ ...currentTelemetry,
57
+ },
58
+ };
59
+ }
60
+ async function migrateLegacyTelemetryConfig(configPath, config, persist) {
61
+ const legacyConfigPath = getLegacyConfigPath();
62
+ if (path.resolve(configPath) === path.resolve(legacyConfigPath) || !hasMissingTelemetryFields(config)) {
63
+ return config;
64
+ }
65
+ const legacyRead = await readConfigFile(legacyConfigPath);
66
+ if (legacyRead.status !== 'ok') {
67
+ return config;
68
+ }
69
+ const migrated = mergeLegacyTelemetry(config, legacyRead.config);
70
+ if (!migrated) {
71
+ return config;
72
+ }
73
+ if (persist) {
74
+ try {
75
+ await writeConfigFile(configPath, migrated);
76
+ }
77
+ catch {
78
+ // Preserve telemetry for this run even if the one-time migration cannot be persisted.
79
+ }
80
+ }
81
+ return migrated;
82
+ }
8
83
  /**
9
84
  * Get the path to the global config file.
10
- * Uses ~/.config/openspec/config.json on all platforms.
85
+ * Follows XDG Base Directory Specification and platform conventions.
86
+ *
87
+ * - All platforms: $XDG_CONFIG_HOME/openspec/ if XDG_CONFIG_HOME is set
88
+ * - Unix/macOS fallback: ~/.config/openspec/
89
+ * - Windows fallback: %APPDATA%/openspec/
11
90
  */
12
91
  export function getConfigPath() {
13
- const configDir = path.join(os.homedir(), '.config', 'openspec');
14
- return path.join(configDir, 'config.json');
92
+ const configDir = getConfigDir();
93
+ return path.join(configDir, CONFIG_FILE_NAME);
15
94
  }
16
95
  /**
17
96
  * Read the global config file.
@@ -19,17 +98,9 @@ export function getConfigPath() {
19
98
  */
20
99
  export async function readConfig() {
21
100
  const configPath = getConfigPath();
22
- try {
23
- const content = await fs.readFile(configPath, 'utf-8');
24
- return JSON.parse(content);
25
- }
26
- catch (error) {
27
- if (error.code === 'ENOENT') {
28
- return {};
29
- }
30
- // If parse fails or other error, return empty config
31
- return {};
32
- }
101
+ const read = await readConfigFile(configPath);
102
+ const config = read.status === 'ok' ? read.config : {};
103
+ return migrateLegacyTelemetryConfig(configPath, config, read.status !== 'invalid');
33
104
  }
34
105
  /**
35
106
  * Write to the global config file.
@@ -37,9 +108,6 @@ export async function readConfig() {
37
108
  */
38
109
  export async function writeConfig(updates) {
39
110
  const configPath = getConfigPath();
40
- const configDir = path.dirname(configPath);
41
- // Ensure directory exists
42
- await fs.mkdir(configDir, { recursive: true });
43
111
  // Read existing config and merge
44
112
  const existing = await readConfig();
45
113
  const merged = { ...existing, ...updates };
@@ -47,7 +115,7 @@ export async function writeConfig(updates) {
47
115
  if (updates.telemetry && existing.telemetry) {
48
116
  merged.telemetry = { ...existing.telemetry, ...updates.telemetry };
49
117
  }
50
- await fs.writeFile(configPath, JSON.stringify(merged, null, 2) + '\n');
118
+ await writeConfigFile(configPath, merged);
51
119
  }
52
120
  /**
53
121
  * Get the telemetry config section.
@@ -16,8 +16,21 @@ import { getTelemetryConfig, updateTelemetryConfig } from './config.js';
16
16
  const POSTHOG_API_KEY = 'phc_Hthu8YvaIJ9QaFKyTG4TbVwkbd5ktcAFzVTKeMmoW2g';
17
17
  // Using reverse proxy to avoid ad blockers and keep traffic on our domain
18
18
  const POSTHOG_HOST = 'https://edge.openspec.dev';
19
+ const TELEMETRY_REQUEST_TIMEOUT_MS = 1000;
19
20
  let posthogClient = null;
20
21
  let anonymousId = null;
22
+ async function safeTelemetryFetch(url, options) {
23
+ try {
24
+ const response = await fetch(url, options);
25
+ if (response.ok) {
26
+ return response;
27
+ }
28
+ }
29
+ catch {
30
+ // Silent failure - telemetry should never surface network noise
31
+ }
32
+ return new Response(null, { status: 204 });
33
+ }
21
34
  /**
22
35
  * Check if telemetry is enabled.
23
36
  *
@@ -71,6 +84,12 @@ function getClient() {
71
84
  host: POSTHOG_HOST,
72
85
  flushAt: 1, // Send immediately, don't batch
73
86
  flushInterval: 0, // No timer-based flushing
87
+ fetchRetryCount: 0,
88
+ requestTimeout: TELEMETRY_REQUEST_TIMEOUT_MS,
89
+ preloadFeatureFlags: false,
90
+ disableRemoteConfig: true,
91
+ disableSurveys: true,
92
+ fetch: safeTelemetryFetch,
74
93
  });
75
94
  }
76
95
  return posthogClient;
@@ -4,6 +4,11 @@ export declare class FileSystemUtils {
4
4
  * Essential for cross-platform compatibility with glob libraries like fast-glob.
5
5
  */
6
6
  static toPosixPath(p: string): string;
7
+ /**
8
+ * Returns a canonical absolute path when the target exists.
9
+ * Falls back to path.resolve() so callers can still produce a stable absolute path.
10
+ */
11
+ static canonicalizeExistingPath(targetPath: string): string;
7
12
  private static isWindowsBasePath;
8
13
  private static normalizeSegments;
9
14
  static joinPath(basePath: string, ...segments: string[]): string;
@@ -1,5 +1,7 @@
1
- import { promises as fs, constants as fsConstants } from 'fs';
1
+ import * as nodeFs from 'fs';
2
2
  import path from 'path';
3
+ const fs = nodeFs.promises;
4
+ const { constants: fsConstants } = nodeFs;
3
5
  function isMarkerOnOwnLine(content, markerIndex, markerLength) {
4
6
  let leftIndex = markerIndex - 1;
5
7
  while (leftIndex >= 0 && content[leftIndex] !== '\n') {
@@ -37,6 +39,24 @@ export class FileSystemUtils {
37
39
  static toPosixPath(p) {
38
40
  return p.replace(/\\/g, '/');
39
41
  }
42
+ /**
43
+ * Returns a canonical absolute path when the target exists.
44
+ * Falls back to path.resolve() so callers can still produce a stable absolute path.
45
+ */
46
+ static canonicalizeExistingPath(targetPath) {
47
+ try {
48
+ // Prefer the native resolver so Windows short-path aliases are expanded.
49
+ return nodeFs.realpathSync.native(targetPath);
50
+ }
51
+ catch {
52
+ try {
53
+ return nodeFs.realpathSync(targetPath);
54
+ }
55
+ catch {
56
+ return path.resolve(targetPath);
57
+ }
58
+ }
59
+ }
40
60
  static isWindowsBasePath(basePath) {
41
61
  return /^[A-Za-z]:[\\/]/.test(basePath) || basePath.startsWith('\\');
42
62
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fission-ai/openspec",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",