@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
@@ -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
@@ -77,7 +77,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
77
77
  Display a table summarizing all changes:
78
78
 
79
79
  \`\`\`
80
- | Change | Artifacts | Tasks | Specs | Conflicts | Status |
80
+ | Change | Artifacts | Tasks | Specs | Conflicts | Status |
81
81
  |---------------------|-----------|-------|---------|-----------|--------|
82
82
  | schema-management | Done | 5/5 | 2 delta | None | Ready |
83
83
  | project-config | Done | 3/3 | 1 delta | None | Ready |
@@ -323,7 +323,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
323
323
  Display a table summarizing all changes:
324
324
 
325
325
  \`\`\`
326
- | Change | Artifacts | Tasks | Specs | Conflicts | Status |
326
+ | Change | Artifacts | Tasks | Specs | Conflicts | Status |
327
327
  |---------------------|-----------|-------|---------|-----------|--------|
328
328
  | schema-management | Done | 5/5 | 2 delta | None | Ready |
329
329
  | project-config | Done | 3/3 | 1 delta | None | Ready |
@@ -49,10 +49,10 @@ Depending on what the user brings, you might:
49
49
  │ Use ASCII diagrams liberally │
50
50
  ├─────────────────────────────────────────┤
51
51
  │ │
52
- │ ┌────────┐ ┌────────┐ │
53
- │ │ State │────────▶│ State │ │
54
- │ │ A │ │ B │ │
55
- │ └────────┘ └────────┘ │
52
+ │ ┌────────┐ ┌────────┐ │
53
+ │ │ State │────────▶│ State │ │
54
+ │ │ A │ │ B │ │
55
+ │ └────────┘ └────────┘ │
56
56
  │ │
57
57
  │ System diagrams, state machines, │
58
58
  │ data flows, architecture sketches, │
@@ -107,14 +107,14 @@ If the user mentions a change or you detect one is relevant:
107
107
 
108
108
  3. **Offer to capture when decisions are made**
109
109
 
110
- | Insight Type | Where to Capture |
111
- |--------------|------------------|
112
- | New requirement discovered | \`specs/<capability>/spec.md\` |
113
- | Requirement changed | \`specs/<capability>/spec.md\` |
114
- | Design decision made | \`design.md\` |
115
- | Scope changed | \`proposal.md\` |
116
- | New work identified | \`tasks.md\` |
117
- | Assumption invalidated | Relevant artifact |
110
+ | Insight Type | Where to Capture |
111
+ |----------------------------|--------------------------------|
112
+ | New requirement discovered | \`specs/<capability>/spec.md\` |
113
+ | Requirement changed | \`specs/<capability>/spec.md\` |
114
+ | Design decision made | \`design.md\` |
115
+ | Scope changed | \`proposal.md\` |
116
+ | New work identified | \`tasks.md\` |
117
+ | Assumption invalidated | Relevant artifact |
118
118
 
119
119
  Example offers:
120
120
  - "That's a design decision. Capture it in design.md?"
@@ -220,7 +220,7 @@ User: A CLI tool that tracks local dev environments
220
220
  You: That changes everything.
221
221
 
222
222
  ┌─────────────────────────────────────────────────┐
223
- │ CLI TOOL DATA STORAGE │
223
+ │ CLI TOOL DATA STORAGE │
224
224
  └─────────────────────────────────────────────────┘
225
225
 
226
226
  Key constraints:
@@ -344,10 +344,10 @@ Depending on what the user brings, you might:
344
344
  │ Use ASCII diagrams liberally │
345
345
  ├─────────────────────────────────────────┤
346
346
  │ │
347
- │ ┌────────┐ ┌────────┐ │
348
- │ │ State │────────▶│ State │ │
349
- │ │ A │ │ B │ │
350
- │ └────────┘ └────────┘ │
347
+ │ ┌────────┐ ┌────────┐ │
348
+ │ │ State │────────▶│ State │ │
349
+ │ │ A │ │ B │ │
350
+ │ └────────┘ └────────┘ │
351
351
  │ │
352
352
  │ System diagrams, state machines, │
353
353
  │ data flows, architecture sketches, │
@@ -404,14 +404,14 @@ If the user mentions a change or you detect one is relevant:
404
404
 
405
405
  3. **Offer to capture when decisions are made**
406
406
 
407
- | Insight Type | Where to Capture |
408
- |--------------|------------------|
409
- | New requirement discovered | \`specs/<capability>/spec.md\` |
410
- | Requirement changed | \`specs/<capability>/spec.md\` |
411
- | Design decision made | \`design.md\` |
412
- | Scope changed | \`proposal.md\` |
413
- | New work identified | \`tasks.md\` |
414
- | Assumption invalidated | Relevant artifact |
407
+ | Insight Type | Where to Capture |
408
+ |----------------------------|--------------------------------|
409
+ | New requirement discovered | \`specs/<capability>/spec.md\` |
410
+ | Requirement changed | \`specs/<capability>/spec.md\` |
411
+ | Design decision made | \`design.md\` |
412
+ | Scope changed | \`proposal.md\` |
413
+ | New work identified | \`tasks.md\` |
414
+ | Assumption invalidated | Relevant artifact |
415
415
 
416
416
  Example offers:
417
417
  - "That's a design decision. Capture it in design.md?"
@@ -468,21 +468,21 @@ This same rhythm works for any size change—a small fix or a major feature.
468
468
 
469
469
  **Core workflow:**
470
470
 
471
- | Command | What it does |
472
- |---------|--------------|
473
- | \`/opsx:propose\` | Create a change and generate all artifacts |
474
- | \`/opsx:explore\` | Think through problems before/during work |
475
- | \`/opsx:apply\` | Implement tasks from a change |
476
- | \`/opsx:archive\` | Archive a completed change |
471
+ | Command | What it does |
472
+ |-------------------|--------------------------------------------|
473
+ | \`/opsx:propose\` | Create a change and generate all artifacts |
474
+ | \`/opsx:explore\` | Think through problems before/during work |
475
+ | \`/opsx:apply\` | Implement tasks from a change |
476
+ | \`/opsx:archive\` | Archive a completed change |
477
477
 
478
478
  **Additional commands:**
479
479
 
480
- | Command | What it does |
481
- |---------|--------------|
482
- | \`/opsx:new\` | Start a new change, step through artifacts one at a time |
483
- | \`/opsx:continue\` | Continue working on an existing change |
484
- | \`/opsx:ff\` | Fast-forward: create all artifacts at once |
485
- | \`/opsx:verify\` | Verify implementation matches artifacts |
480
+ | Command | What it does |
481
+ |--------------------|----------------------------------------------------------|
482
+ | \`/opsx:new\` | Start a new change, step through artifacts one at a time |
483
+ | \`/opsx:continue\` | Continue working on an existing change |
484
+ | \`/opsx:ff\` | Fast-forward: create all artifacts at once |
485
+ | \`/opsx:verify\` | Verify implementation matches artifacts |
486
486
 
487
487
  ---
488
488
 
@@ -520,21 +520,21 @@ If the user says they just want to see the commands or skip the tutorial:
520
520
 
521
521
  **Core workflow:**
522
522
 
523
- | Command | What it does |
524
- |---------|--------------|
525
- | \`/opsx:propose <name>\` | Create a change and generate all artifacts |
526
- | \`/opsx:explore\` | Think through problems (no code changes) |
527
- | \`/opsx:apply <name>\` | Implement tasks |
528
- | \`/opsx:archive <name>\` | Archive when done |
523
+ | Command | What it does |
524
+ |--------------------------|--------------------------------------------|
525
+ | \`/opsx:propose <name>\` | Create a change and generate all artifacts |
526
+ | \`/opsx:explore\` | Think through problems (no code changes) |
527
+ | \`/opsx:apply <name>\` | Implement tasks |
528
+ | \`/opsx:archive <name>\` | Archive when done |
529
529
 
530
530
  **Additional commands:**
531
531
 
532
- | Command | What it does |
533
- |---------|--------------|
534
- | \`/opsx:new <name>\` | Start a new change, step by step |
535
- | \`/opsx:continue <name>\` | Continue an existing change |
536
- | \`/opsx:ff <name>\` | Fast-forward: all artifacts at once |
537
- | \`/opsx:verify <name>\` | Verify implementation |
532
+ | Command | What it does |
533
+ |---------------------------|-------------------------------------|
534
+ | \`/opsx:new <name>\` | Start a new change, step by step |
535
+ | \`/opsx:continue <name>\` | Continue an existing change |
536
+ | \`/opsx:ff <name>\` | Fast-forward: all artifacts at once |
537
+ | \`/opsx:verify <name>\` | Verify implementation |
538
538
 
539
539
  Try \`/opsx:propose\` to start your first change.
540
540
  \`\`\`
@@ -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:
@@ -129,7 +129,7 @@ export class UpdateCommand {
129
129
  const skillDir = path.join(skillsDir, dirName);
130
130
  const skillFile = path.join(skillDir, 'SKILL.md');
131
131
  // Use hyphen-based command references for OpenCode
132
- const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
132
+ const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
133
133
  const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
134
134
  await FileSystemUtils.writeFile(skillFile, skillContent);
135
135
  }
@@ -504,7 +504,7 @@ export class UpdateCommand {
504
504
  const skillDir = path.join(skillsDir, dirName);
505
505
  const skillFile = path.join(skillDir, 'SKILL.md');
506
506
  // Use hyphen-based command references for OpenCode
507
- const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
507
+ const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
508
508
  const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
509
509
  await FileSystemUtils.writeFile(skillFile, skillContent);
510
510
  }
@@ -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.