@fission-ai/openspec 1.5.0 → 1.6.0-beta.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 (48) hide show
  1. package/README.md +13 -1
  2. package/dist/commands/change.d.ts +0 -1
  3. package/dist/commands/change.js +6 -40
  4. package/dist/commands/validate.d.ts +8 -0
  5. package/dist/commands/validate.js +16 -4
  6. package/dist/core/archive.js +15 -16
  7. package/dist/core/command-generation/adapters/claude.d.ts +1 -1
  8. package/dist/core/command-generation/adapters/claude.js +3 -1
  9. package/dist/core/command-generation/adapters/index.d.ts +2 -0
  10. package/dist/core/command-generation/adapters/index.js +2 -0
  11. package/dist/core/command-generation/adapters/oh-my-pi.d.ts +21 -0
  12. package/dist/core/command-generation/adapters/oh-my-pi.js +44 -0
  13. package/dist/core/command-generation/adapters/trae.d.ts +13 -0
  14. package/dist/core/command-generation/adapters/trae.js +48 -0
  15. package/dist/core/command-generation/registry.js +4 -0
  16. package/dist/core/config.js +1 -0
  17. package/dist/core/init.js +3 -2
  18. package/dist/core/list.js +18 -9
  19. package/dist/core/openspec-root.js +18 -15
  20. package/dist/core/parsers/change-parser.js +2 -1
  21. package/dist/core/parsers/markdown-parser.d.ts +0 -3
  22. package/dist/core/parsers/markdown-parser.js +5 -59
  23. package/dist/core/parsers/requirement-blocks.d.ts +11 -0
  24. package/dist/core/parsers/requirement-blocks.js +42 -8
  25. package/dist/core/parsers/requirement-text.d.ts +55 -0
  26. package/dist/core/parsers/requirement-text.js +136 -0
  27. package/dist/core/profile-sync-drift.js +1 -0
  28. package/dist/core/profiles.d.ts +2 -2
  29. package/dist/core/profiles.js +2 -1
  30. package/dist/core/schemas/base.schema.js +7 -2
  31. package/dist/core/shared/allowed-tools.d.ts +12 -0
  32. package/dist/core/shared/allowed-tools.js +12 -0
  33. package/dist/core/shared/skill-generation.js +5 -1
  34. package/dist/core/shared/tool-detection.d.ts +2 -2
  35. package/dist/core/shared/tool-detection.js +2 -0
  36. package/dist/core/specs-apply.js +35 -1
  37. package/dist/core/store/operations.js +20 -0
  38. package/dist/core/templates/skill-templates.d.ts +1 -0
  39. package/dist/core/templates/skill-templates.js +1 -0
  40. package/dist/core/templates/workflows/update-change.d.ts +10 -0
  41. package/dist/core/templates/workflows/update-change.js +167 -0
  42. package/dist/core/update.js +4 -4
  43. package/dist/core/validation/validator.d.ts +7 -0
  44. package/dist/core/validation/validator.js +101 -38
  45. package/dist/core/view.js +1 -1
  46. package/dist/utils/task-progress.d.ts +10 -1
  47. package/dist/utils/task-progress.js +66 -2
  48. package/package.json +21 -18
package/README.md CHANGED
@@ -85,6 +85,18 @@ AI: Archived to openspec/changes/archive/2025-01-23-add-dark-mode/
85
85
 
86
86
  </details>
87
87
 
88
+ ## Why teams adopt OpenSpec
89
+
90
+ Solo, OpenSpec keeps you and your AI honest on a single repo. On a team, the hard part moves: a feature spans the API server, the web app, and a shared library; requirements are owned by one team and consumed by others; planning starts before any code exists.
91
+
92
+ **[Stores](docs/stores-beta/user-guide.md)** are the answer — planning in a repo of its own. The same `openspec/` shape you already know (specs and changes), shared by `git push` like anything else. One source of truth your whole team and every coding agent can read, across every repo.
93
+
94
+ - **Cross-repo features** — one change, one plan, even when the code lands in three repos.
95
+ - **Shared requirements** — a platform team owns the specs; product teams reference them read-only, right where their coding agent can read them. No drifting wiki.
96
+ - **Plan before code** — capture the plan in the store now; the code repos catch up later.
97
+
98
+ > Stores are in **beta**. Start with the [Stores User Guide](docs/stores-beta/user-guide.md).
99
+
88
100
  ## Quick Start
89
101
 
90
102
  **Requires Node.js 20.19.0 or higher.**
@@ -150,7 +162,7 @@ AI coding assistants are powerful but unpredictable when requirements live only
150
162
  - **Agree before you build** — human and AI align on specs before code gets written
151
163
  - **Stay organized** — each change gets its own folder with proposal, specs, design, and tasks
152
164
  - **Work fluidly** — update any artifact anytime, no rigid phase gates
153
- - **Use your tools** — works with 20+ AI assistants via slash commands
165
+ - **Use your tools** — works with 30+ AI assistants via slash commands
154
166
 
155
167
  ### How we compare
156
168
 
@@ -33,7 +33,6 @@ export declare class ChangeCommand {
33
33
  }): Promise<void>;
34
34
  private getActiveChanges;
35
35
  private extractTitle;
36
- private countTasks;
37
36
  private printNextSteps;
38
37
  }
39
38
  //# sourceMappingURL=change.d.ts.map
@@ -5,10 +5,9 @@ import { Validator } from '../core/validation/validator.js';
5
5
  import { ChangeParser } from '../core/parsers/change-parser.js';
6
6
  import { isInteractive } from '../utils/interactive.js';
7
7
  import { getActiveChangeIds } from '../utils/item-discovery.js';
8
+ import { getTaskProgressForChange } from '../utils/task-progress.js';
8
9
  // Constants for better maintainability
9
10
  const ARCHIVE_DIR = 'archive';
10
- const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i;
11
- const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i;
12
11
  export class ChangeCommand {
13
12
  converter;
14
13
  rootPath;
@@ -94,23 +93,14 @@ export class ChangeCommand {
94
93
  if (options?.json) {
95
94
  const changeDetails = await Promise.all(changes.map(async (changeName) => {
96
95
  const proposalPath = path.join(changesPath, changeName, 'proposal.md');
97
- const tasksPath = path.join(changesPath, changeName, 'tasks.md');
98
96
  try {
99
97
  const content = await fs.readFile(proposalPath, 'utf-8');
100
98
  const changeDir = path.join(changesPath, changeName);
101
99
  const parser = new ChangeParser(content, changeDir);
102
100
  const change = await parser.parseChangeWithDeltas(changeName);
103
- let taskStatus = { total: 0, completed: 0 };
104
- try {
105
- const tasksContent = await fs.readFile(tasksPath, 'utf-8');
106
- taskStatus = this.countTasks(tasksContent);
107
- }
108
- catch (error) {
109
- // Tasks file may not exist, which is okay
110
- if (process.env.DEBUG) {
111
- console.error(`Failed to read tasks file at ${tasksPath}:`, error);
112
- }
113
- }
101
+ // Resolve task progress through the shared tracked-tasks helper so
102
+ // this deprecated noun-form list cannot re-fork the resolution (#1202).
103
+ const taskStatus = await getTaskProgressForChange(changesPath, changeName, process.cwd());
114
104
  return {
115
105
  id: changeName,
116
106
  title: this.extractTitle(content, changeName),
@@ -144,21 +134,11 @@ export class ChangeCommand {
144
134
  // Long format: id: title and minimal counts
145
135
  for (const changeName of sorted) {
146
136
  const proposalPath = path.join(changesPath, changeName, 'proposal.md');
147
- const tasksPath = path.join(changesPath, changeName, 'tasks.md');
148
137
  try {
149
138
  const content = await fs.readFile(proposalPath, 'utf-8');
150
139
  const title = this.extractTitle(content, changeName);
151
- let taskStatusText = '';
152
- try {
153
- const tasksContent = await fs.readFile(tasksPath, 'utf-8');
154
- const { total, completed } = this.countTasks(tasksContent);
155
- taskStatusText = ` [tasks ${completed}/${total}]`;
156
- }
157
- catch (error) {
158
- if (process.env.DEBUG) {
159
- console.error(`Failed to read tasks file at ${tasksPath}:`, error);
160
- }
161
- }
140
+ const { total, completed } = await getTaskProgressForChange(changesPath, changeName, process.cwd());
141
+ const taskStatusText = total > 0 ? ` [tasks ${completed}/${total}]` : '';
162
142
  const changeDir = path.join(changesPath, changeName);
163
143
  const parser = new ChangeParser(await fs.readFile(proposalPath, 'utf-8'), changeDir);
164
144
  const change = await parser.parseChangeWithDeltas(changeName);
@@ -253,20 +233,6 @@ export class ChangeCommand {
253
233
  const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/im);
254
234
  return match ? match[1].trim() : changeName;
255
235
  }
256
- countTasks(content) {
257
- const lines = content.split('\n');
258
- let total = 0;
259
- let completed = 0;
260
- for (const line of lines) {
261
- if (line.match(TASK_PATTERN)) {
262
- total++;
263
- if (line.match(COMPLETED_TASK_PATTERN)) {
264
- completed++;
265
- }
266
- }
267
- }
268
- return { total, completed };
269
- }
270
236
  printNextSteps() {
271
237
  const bullets = [];
272
238
  bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements');
@@ -14,6 +14,14 @@ interface ExecuteOptions {
14
14
  export declare class ValidateCommand {
15
15
  execute(itemName: string | undefined, options?: ExecuteOptions): Promise<void>;
16
16
  private normalizeType;
17
+ /**
18
+ * Resolve change IDs by directory existence within the resolved root — the
19
+ * same rule `openspec status`/`instructions` use (`getAvailableChanges`) —
20
+ * rather than requiring `proposal.md`. This lets `validate` resolve a
21
+ * scaffolded or still-authoring change that the sibling commands already
22
+ * resolve (#1182). Sorted to preserve the prior `getActiveChangeIds` ordering.
23
+ */
24
+ private listChangeIds;
17
25
  private runInteractiveSelector;
18
26
  private printNonInteractiveHint;
19
27
  private validateDirectItem;
@@ -3,7 +3,8 @@ import path from 'path';
3
3
  import { Validator } from '../core/validation/validator.js';
4
4
  import { resolveRootForCommand, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from '../core/root-selection.js';
5
5
  import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
6
- import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
6
+ import { getSpecIds } from '../utils/item-discovery.js';
7
+ import { getAvailableChanges } from './workflow/shared.js';
7
8
  import { nearestMatches } from '../utils/match.js';
8
9
  export class ValidateCommand {
9
10
  async execute(itemName, options = {}) {
@@ -42,6 +43,17 @@ export class ValidateCommand {
42
43
  return v;
43
44
  return undefined;
44
45
  }
46
+ /**
47
+ * Resolve change IDs by directory existence within the resolved root — the
48
+ * same rule `openspec status`/`instructions` use (`getAvailableChanges`) —
49
+ * rather than requiring `proposal.md`. This lets `validate` resolve a
50
+ * scaffolded or still-authoring change that the sibling commands already
51
+ * resolve (#1182). Sorted to preserve the prior `getActiveChangeIds` ordering.
52
+ */
53
+ async listChangeIds(root) {
54
+ const ids = await getAvailableChanges(root.path, root.changesDir);
55
+ return ids.sort();
56
+ }
45
57
  async runInteractiveSelector(root, opts) {
46
58
  const { select } = await import('@inquirer/prompts');
47
59
  const choice = await select({
@@ -60,7 +72,7 @@ export class ValidateCommand {
60
72
  if (choice === 'specs')
61
73
  return this.runBulkValidation(root, { changes: false, specs: true }, opts);
62
74
  // one
63
- const [changes, specs] = await Promise.all([getActiveChangeIds(root.path), getSpecIds(root.path)]);
75
+ const [changes, specs] = await Promise.all([this.listChangeIds(root), getSpecIds(root.path)]);
64
76
  const items = [];
65
77
  items.push(...changes.map(id => ({ name: `change/${id}`, value: { type: 'change', id } })));
66
78
  items.push(...specs.map(id => ({ name: `spec/${id}`, value: { type: 'spec', id } })));
@@ -81,7 +93,7 @@ export class ValidateCommand {
81
93
  console.error('Or run in an interactive terminal.');
82
94
  }
83
95
  async validateDirectItem(root, itemName, opts) {
84
- const [changes, specs] = await Promise.all([getActiveChangeIds(root.path), getSpecIds(root.path)]);
96
+ const [changes, specs] = await Promise.all([this.listChangeIds(root), getSpecIds(root.path)]);
85
97
  const isChange = changes.includes(itemName);
86
98
  const isSpec = specs.includes(itemName);
87
99
  const type = opts.typeOverride ?? (isChange ? 'change' : isSpec ? 'spec' : undefined);
@@ -183,7 +195,7 @@ export class ValidateCommand {
183
195
  async runBulkValidation(root, scope, opts) {
184
196
  const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined;
185
197
  const [changeIds, specIds] = await Promise.all([
186
- scope.changes ? getActiveChangeIds(root.path) : Promise.resolve([]),
198
+ scope.changes ? this.listChangeIds(root) : Promise.resolve([]),
187
199
  scope.specs ? getSpecIds(root.path) : Promise.resolve([]),
188
200
  ]);
189
201
  const DEFAULT_CONCURRENCY = 6;
@@ -5,6 +5,12 @@ import { Validator } from './validation/validator.js';
5
5
  import chalk from 'chalk';
6
6
  import { emitStoreRootBanner, isRootSelectionError, resolveOpenSpecRoot, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from './root-selection.js';
7
7
  import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, } from './specs-apply.js';
8
+ function isMissingPathError(error) {
9
+ return (typeof error === 'object' &&
10
+ error !== null &&
11
+ 'code' in error &&
12
+ error.code === 'ENOENT');
13
+ }
8
14
  async function listActiveChangeNames(changesDir) {
9
15
  try {
10
16
  const entries = await fs.readdir(changesDir, { withFileTypes: true });
@@ -13,7 +19,9 @@ async function listActiveChangeNames(changesDir) {
13
19
  .map((entry) => entry.name)
14
20
  .sort();
15
21
  }
16
- catch {
22
+ catch (error) {
23
+ if (!isMissingPathError(error))
24
+ throw error;
17
25
  return [];
18
26
  }
19
27
  }
@@ -136,13 +144,6 @@ export class ArchiveCommand {
136
144
  const changesDir = root.changesDir;
137
145
  const archiveDir = root.archiveDir;
138
146
  const mainSpecsDir = root.specsDir;
139
- // Check if changes directory exists
140
- try {
141
- await fs.access(changesDir);
142
- }
143
- catch {
144
- throw new Error("No OpenSpec changes directory found. Run 'openspec init' first.");
145
- }
146
147
  // Get change name interactively if not provided
147
148
  if (!changeName) {
148
149
  if (json) {
@@ -237,6 +238,7 @@ export class ArchiveCommand {
237
238
  }
238
239
  console.log(chalk.red('\nValidation failed. Please fix the errors before archiving.'));
239
240
  console.log(chalk.yellow('To skip validation (not recommended), use --no-validate flag.'));
241
+ process.exitCode = 1;
240
242
  return null;
241
243
  }
242
244
  }
@@ -266,7 +268,7 @@ export class ArchiveCommand {
266
268
  console.log(chalk.yellow(`Affected files: ${changeDir}`));
267
269
  }
268
270
  // Show progress and check for incomplete tasks
269
- const progress = await getTaskProgressForChange(changesDir, changeName);
271
+ const progress = await getTaskProgressForChange(changesDir, changeName, path.resolve(changesDir, '..', '..'));
270
272
  if (!json) {
271
273
  const status = formatTaskStatus(progress);
272
274
  console.log(`Task status: ${status}`);
@@ -342,6 +344,7 @@ export class ArchiveCommand {
342
344
  }
343
345
  console.log(String(err.message || err));
344
346
  console.log('Aborted. No files were changed.');
347
+ process.exitCode = 1;
345
348
  return null;
346
349
  }
347
350
  // Validate every rebuilt spec before writing any of them, so a
@@ -362,6 +365,7 @@ export class ArchiveCommand {
362
365
  console.log(chalk.yellow(` ⚠ ${issue.message}`));
363
366
  }
364
367
  console.log('Aborted. No files were changed.');
368
+ process.exitCode = 1;
365
369
  return null;
366
370
  }
367
371
  }
@@ -422,12 +426,7 @@ export class ArchiveCommand {
422
426
  }
423
427
  async selectChange(changesDir) {
424
428
  const { select } = await import('@inquirer/prompts');
425
- // Get all directories in changes (excluding archive)
426
- const entries = await fs.readdir(changesDir, { withFileTypes: true });
427
- const changeDirs = entries
428
- .filter(entry => entry.isDirectory() && entry.name !== 'archive')
429
- .map(entry => entry.name)
430
- .sort();
429
+ const changeDirs = await listActiveChangeNames(changesDir);
431
430
  if (changeDirs.length === 0) {
432
431
  console.log('No active changes found.');
433
432
  return null;
@@ -437,7 +436,7 @@ export class ArchiveCommand {
437
436
  try {
438
437
  const progressList = [];
439
438
  for (const id of changeDirs) {
440
- const progress = await getTaskProgressForChange(changesDir, id);
439
+ const progress = await getTaskProgressForChange(changesDir, id, path.resolve(changesDir, '..', '..'));
441
440
  const status = formatTaskStatus(progress);
442
441
  progressList.push({ id, status });
443
442
  }
@@ -7,7 +7,7 @@ import type { ToolCommandAdapter } from '../types.js';
7
7
  /**
8
8
  * Claude Code adapter for command generation.
9
9
  * File path: .claude/commands/opsx/<id>.md
10
- * Frontmatter: name, description, category, tags
10
+ * Frontmatter: name, description, allowed-tools, category, tags
11
11
  */
12
12
  export declare const claudeAdapter: ToolCommandAdapter;
13
13
  //# sourceMappingURL=claude.d.ts.map
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import path from 'path';
7
7
  import { escapeYamlValue } from '../yaml.js';
8
+ import { OPENSPEC_CLI_ALLOWED_TOOLS } from '../../shared/allowed-tools.js';
8
9
  /**
9
10
  * Formats a tags array as a YAML array with proper escaping.
10
11
  */
@@ -15,7 +16,7 @@ function formatTagsArray(tags) {
15
16
  /**
16
17
  * Claude Code adapter for command generation.
17
18
  * File path: .claude/commands/opsx/<id>.md
18
- * Frontmatter: name, description, category, tags
19
+ * Frontmatter: name, description, allowed-tools, category, tags
19
20
  */
20
21
  export const claudeAdapter = {
21
22
  toolId: 'claude',
@@ -26,6 +27,7 @@ export const claudeAdapter = {
26
27
  return `---
27
28
  name: ${escapeYamlValue(content.name)}
28
29
  description: ${escapeYamlValue(content.description)}
30
+ allowed-tools: ${OPENSPEC_CLI_ALLOWED_TOOLS}
29
31
  category: ${escapeYamlValue(content.category)}
30
32
  tags: ${formatTagsArray(content.tags)}
31
33
  ---
@@ -22,11 +22,13 @@ export { iflowAdapter } from './iflow.js';
22
22
  export { junieAdapter } from './junie.js';
23
23
  export { kilocodeAdapter } from './kilocode.js';
24
24
  export { kiroAdapter } from './kiro.js';
25
+ export { ohMyPiAdapter } from './oh-my-pi.js';
25
26
  export { opencodeAdapter } from './opencode.js';
26
27
  export { piAdapter } from './pi.js';
27
28
  export { qoderAdapter } from './qoder.js';
28
29
  export { lingmaAdapter } from './lingma.js';
29
30
  export { qwenAdapter } from './qwen.js';
30
31
  export { roocodeAdapter } from './roocode.js';
32
+ export { traeAdapter } from './trae.js';
31
33
  export { windsurfAdapter } from './windsurf.js';
32
34
  //# sourceMappingURL=index.d.ts.map
@@ -22,11 +22,13 @@ export { iflowAdapter } from './iflow.js';
22
22
  export { junieAdapter } from './junie.js';
23
23
  export { kilocodeAdapter } from './kilocode.js';
24
24
  export { kiroAdapter } from './kiro.js';
25
+ export { ohMyPiAdapter } from './oh-my-pi.js';
25
26
  export { opencodeAdapter } from './opencode.js';
26
27
  export { piAdapter } from './pi.js';
27
28
  export { qoderAdapter } from './qoder.js';
28
29
  export { lingmaAdapter } from './lingma.js';
29
30
  export { qwenAdapter } from './qwen.js';
30
31
  export { roocodeAdapter } from './roocode.js';
32
+ export { traeAdapter } from './trae.js';
31
33
  export { windsurfAdapter } from './windsurf.js';
32
34
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Oh My Pi (OMP) Command Adapter
3
+ *
4
+ * Formats commands for Oh My Pi following its slash command specification.
5
+ * OMP loads slash commands from .omp/commands/*.md with YAML frontmatter.
6
+ * The filename (minus .md) becomes the slash command name.
7
+ */
8
+ import type { ToolCommandAdapter } from '../types.js';
9
+ /**
10
+ * Oh My Pi adapter for command generation.
11
+ * File path: .omp/commands/opsx-<id>.md
12
+ * Frontmatter: description
13
+ *
14
+ * OMP uses the filename (minus .md) as the slash command name, so
15
+ * opsx-propose.md → /opsx-propose. Command references in the body
16
+ * are transformed from /opsx: to /opsx- for consistency, and
17
+ * $@ is injected after **Input**: headings so user-supplied arguments
18
+ * (e.g. /opsx-propose my-feature) are visible to the agent.
19
+ */
20
+ export declare const ohMyPiAdapter: ToolCommandAdapter;
21
+ //# sourceMappingURL=oh-my-pi.d.ts.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Oh My Pi (OMP) Command Adapter
3
+ *
4
+ * Formats commands for Oh My Pi following its slash command specification.
5
+ * OMP loads slash commands from .omp/commands/*.md with YAML frontmatter.
6
+ * The filename (minus .md) becomes the slash command name.
7
+ */
8
+ import path from 'path';
9
+ import { transformToHyphenCommands } from '../../../utils/command-references.js';
10
+ import { escapeYamlValue } from '../yaml.js';
11
+ const OMP_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m;
12
+ function injectOmpArgs(body) {
13
+ if (body.includes('$@') || body.includes('$ARGUMENTS')) {
14
+ return body;
15
+ }
16
+ return body.replace(OMP_INPUT_HEADING, (heading) => `${heading}\n**Provided arguments**: $@`);
17
+ }
18
+ /**
19
+ * Oh My Pi adapter for command generation.
20
+ * File path: .omp/commands/opsx-<id>.md
21
+ * Frontmatter: description
22
+ *
23
+ * OMP uses the filename (minus .md) as the slash command name, so
24
+ * opsx-propose.md → /opsx-propose. Command references in the body
25
+ * are transformed from /opsx: to /opsx- for consistency, and
26
+ * $@ is injected after **Input**: headings so user-supplied arguments
27
+ * (e.g. /opsx-propose my-feature) are visible to the agent.
28
+ */
29
+ export const ohMyPiAdapter = {
30
+ toolId: 'oh-my-pi',
31
+ getFilePath(commandId) {
32
+ return path.join('.omp', 'commands', `opsx-${commandId}.md`);
33
+ },
34
+ formatFile(content) {
35
+ const transformedBody = transformToHyphenCommands(content.body);
36
+ return `---
37
+ description: ${escapeYamlValue(content.description)}
38
+ ---
39
+
40
+ ${injectOmpArgs(transformedBody)}
41
+ `;
42
+ },
43
+ };
44
+ //# sourceMappingURL=oh-my-pi.js.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Trae Command Adapter
3
+ *
4
+ * Formats commands for Trae IDE following its command specification.
5
+ */
6
+ import type { ToolCommandAdapter } from '../types.js';
7
+ /**
8
+ * Trae adapter for command generation.
9
+ * File path: .trae/commands/opsx-<id>.md
10
+ * Frontmatter: name, description
11
+ */
12
+ export declare const traeAdapter: ToolCommandAdapter;
13
+ //# sourceMappingURL=trae.d.ts.map
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Trae Command Adapter
3
+ *
4
+ * Formats commands for Trae IDE following its command specification.
5
+ */
6
+ import path from 'path';
7
+ /**
8
+ * Escapes a string value for safe YAML output.
9
+ * Quotes the string if it contains special YAML characters.
10
+ */
11
+ function escapeYamlValue(value) {
12
+ if (value === '') {
13
+ return '""';
14
+ }
15
+ // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace)
16
+ const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value);
17
+ if (needsQuoting) {
18
+ // Use double quotes and escape internal double quotes, backslashes, and newlines
19
+ const escaped = value
20
+ .replace(/\\/g, '\\\\')
21
+ .replace(/"/g, '\\"')
22
+ .replace(/\n/g, '\\n')
23
+ .replace(/\r/g, '\\r');
24
+ return `"${escaped}"`;
25
+ }
26
+ return value;
27
+ }
28
+ /**
29
+ * Trae adapter for command generation.
30
+ * File path: .trae/commands/opsx-<id>.md
31
+ * Frontmatter: name, description
32
+ */
33
+ export const traeAdapter = {
34
+ toolId: 'trae',
35
+ getFilePath(commandId) {
36
+ return path.join('.trae', 'commands', `opsx-${commandId}.md`);
37
+ },
38
+ formatFile(content) {
39
+ return `---
40
+ name: ${escapeYamlValue(content.name)}
41
+ description: ${escapeYamlValue(content.description)}
42
+ ---
43
+
44
+ ${content.body}
45
+ `;
46
+ },
47
+ };
48
+ //# sourceMappingURL=trae.js.map
@@ -23,12 +23,14 @@ import { iflowAdapter } from './adapters/iflow.js';
23
23
  import { junieAdapter } from './adapters/junie.js';
24
24
  import { kilocodeAdapter } from './adapters/kilocode.js';
25
25
  import { kiroAdapter } from './adapters/kiro.js';
26
+ import { ohMyPiAdapter } from './adapters/oh-my-pi.js';
26
27
  import { opencodeAdapter } from './adapters/opencode.js';
27
28
  import { piAdapter } from './adapters/pi.js';
28
29
  import { qoderAdapter } from './adapters/qoder.js';
29
30
  import { lingmaAdapter } from './adapters/lingma.js';
30
31
  import { qwenAdapter } from './adapters/qwen.js';
31
32
  import { roocodeAdapter } from './adapters/roocode.js';
33
+ import { traeAdapter } from './adapters/trae.js';
32
34
  import { windsurfAdapter } from './adapters/windsurf.js';
33
35
  /**
34
36
  * Registry for looking up tool command adapters.
@@ -56,12 +58,14 @@ export class CommandAdapterRegistry {
56
58
  CommandAdapterRegistry.register(junieAdapter);
57
59
  CommandAdapterRegistry.register(kilocodeAdapter);
58
60
  CommandAdapterRegistry.register(kiroAdapter);
61
+ CommandAdapterRegistry.register(ohMyPiAdapter);
59
62
  CommandAdapterRegistry.register(opencodeAdapter);
60
63
  CommandAdapterRegistry.register(piAdapter);
61
64
  CommandAdapterRegistry.register(qoderAdapter);
62
65
  CommandAdapterRegistry.register(lingmaAdapter);
63
66
  CommandAdapterRegistry.register(qwenAdapter);
64
67
  CommandAdapterRegistry.register(roocodeAdapter);
68
+ CommandAdapterRegistry.register(traeAdapter);
65
69
  CommandAdapterRegistry.register(windsurfAdapter);
66
70
  }
67
71
  /**
@@ -27,6 +27,7 @@ export const AI_TOOLS = [
27
27
  { name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' },
28
28
  { name: 'Lingma', value: 'lingma', available: true, successLabel: 'Lingma', skillsDir: '.lingma' },
29
29
  { name: 'Mistral Vibe', value: 'vibe', available: true, successLabel: 'Mistral Vibe', skillsDir: '.vibe' },
30
+ { name: 'Oh My Pi', value: 'oh-my-pi', available: true, successLabel: 'Oh My Pi', skillsDir: '.omp' },
30
31
  { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' },
31
32
  { name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' },
32
33
  { name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' },
package/dist/core/init.js CHANGED
@@ -39,6 +39,7 @@ const WORKFLOW_TO_SKILL_DIR = {
39
39
  'new': 'openspec-new-change',
40
40
  'continue': 'openspec-continue-change',
41
41
  'apply': 'openspec-apply-change',
42
+ 'update': 'openspec-update-change',
42
43
  'ff': 'openspec-ff-change',
43
44
  'sync': 'openspec-sync-specs',
44
45
  'archive': 'openspec-archive-change',
@@ -403,8 +404,8 @@ export class InitCommand {
403
404
  const skillDir = path.join(skillsDir, dirName);
404
405
  const skillFile = path.join(skillDir, 'SKILL.md');
405
406
  // Generate SKILL.md content with YAML frontmatter including generatedBy
406
- // Use hyphen-based command references for tools where filename = command name
407
- const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
407
+ // Use hyphen-based command references for tools where filename === command name (oh-my-pi, opencode, pi)
408
+ const transformer = (tool.value === 'opencode' || tool.value === 'pi' || tool.value === 'oh-my-pi') ? transformToHyphenCommands : undefined;
408
409
  const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
409
410
  // Write the skill file
410
411
  await FileSystemUtils.writeFile(skillFile, skillContent);
package/dist/core/list.js CHANGED
@@ -4,6 +4,22 @@ import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progre
4
4
  import { readFileSync } from 'fs';
5
5
  import { join } from 'path';
6
6
  import { MarkdownParser } from './parsers/markdown-parser.js';
7
+ function isMissingPathError(error) {
8
+ return (typeof error === 'object' &&
9
+ error !== null &&
10
+ 'code' in error &&
11
+ error.code === 'ENOENT');
12
+ }
13
+ async function readChangeDirectoryEntries(changesDir) {
14
+ try {
15
+ return await fs.readdir(changesDir, { withFileTypes: true });
16
+ }
17
+ catch (error) {
18
+ if (isMissingPathError(error))
19
+ return [];
20
+ throw error;
21
+ }
22
+ }
7
23
  /**
8
24
  * Get the most recent modification time of any file in a directory (recursive).
9
25
  * Falls back to the directory's own mtime if no files are found.
@@ -64,15 +80,8 @@ export class ListCommand {
64
80
  const { sort = 'recent', json = false, root } = options;
65
81
  if (mode === 'changes') {
66
82
  const changesDir = path.join(targetPath, 'openspec', 'changes');
67
- // Check if changes directory exists
68
- try {
69
- await fs.access(changesDir);
70
- }
71
- catch {
72
- throw new Error("No OpenSpec changes directory found. Run 'openspec init' first.");
73
- }
74
83
  // Get all directories in changes (excluding archive)
75
- const entries = await fs.readdir(changesDir, { withFileTypes: true });
84
+ const entries = await readChangeDirectoryEntries(changesDir);
76
85
  const changeDirs = entries
77
86
  .filter(entry => entry.isDirectory() && entry.name !== 'archive')
78
87
  .map(entry => entry.name);
@@ -88,7 +97,7 @@ export class ListCommand {
88
97
  // Collect information about each change
89
98
  const changes = [];
90
99
  for (const changeDir of changeDirs) {
91
- const progress = await getTaskProgressForChange(changesDir, changeDir);
100
+ const progress = await getTaskProgressForChange(changesDir, changeDir, targetPath);
92
101
  const changePath = path.join(changesDir, changeDir);
93
102
  const lastModified = await getLastModified(changePath);
94
103
  changes.push({
@@ -11,8 +11,8 @@ export const OPENSPEC_CHANGES_DIR = 'openspec/changes';
11
11
  export const OPENSPEC_ARCHIVE_DIR = 'openspec/changes/archive';
12
12
  export const DEFAULT_OPENSPEC_SCHEMA = 'spec-driven';
13
13
  export const DIRECTORY_ANCHOR_FILE_NAME = '.gitkeep';
14
- // Git cannot track empty directories, so clones of a fresh store would lose
15
- // these and fail root-health checks. Anchored at setup time.
14
+ // Git cannot track empty directories, so setup anchors otherwise-empty
15
+ // conventional store directories for teammates who clone the repo later.
16
16
  export const ANCHORED_OPENSPEC_DIRS = [OPENSPEC_SPECS_DIR, OPENSPEC_ARCHIVE_DIR];
17
17
  async function pathKind(targetPath) {
18
18
  try {
@@ -51,6 +51,14 @@ function unresolvedInspection() {
51
51
  function missingDirectoryDiagnostic(code, message, target) {
52
52
  return makeStoreDiagnostic('error', code, message, { target });
53
53
  }
54
+ async function inspectOptionalPlanningDirectory(inspection, storeRoot, key, relativePath, notDirectoryCode, target) {
55
+ const kind = await pathKind(path.join(storeRoot, relativePath));
56
+ inspection[key] = { present: kind === 'directory' };
57
+ if (kind === 'directory' || kind === 'missing')
58
+ return kind;
59
+ inspection.diagnostics.push(missingDirectoryDiagnostic(notDirectoryCode, `${relativePath}/ exists but is not a directory.`, target));
60
+ return kind;
61
+ }
54
62
  export async function inspectOpenSpecRoot(storeRoot) {
55
63
  const rootKind = await pathKind(storeRoot);
56
64
  const inspection = unresolvedInspection();
@@ -90,23 +98,18 @@ export async function inspectOpenSpecRoot(storeRoot) {
90
98
  inspection.diagnostics.push(missingDirectoryDiagnostic('openspec_config_missing', 'Missing openspec/config.yaml or openspec/config.yml.', 'openspec.config'));
91
99
  }
92
100
  }
93
- for (const [key, relativePath, code, message, target] of [
94
- ['specs', OPENSPEC_SPECS_DIR, 'openspec_specs_missing', 'Missing openspec/specs/.', 'openspec.specs'],
95
- ['changes', OPENSPEC_CHANGES_DIR, 'openspec_changes_missing', 'Missing openspec/changes/.', 'openspec.changes'],
96
- ['archive', OPENSPEC_ARCHIVE_DIR, 'openspec_archive_missing', 'Missing openspec/changes/archive/.', 'openspec.archive'],
97
- ]) {
98
- const kind = await pathKind(path.join(storeRoot, relativePath));
99
- inspection[key] = { present: kind === 'directory' };
100
- if (kind === 'directory')
101
- continue;
102
- inspection.diagnostics.push(missingDirectoryDiagnostic(kind === 'missing' ? code : code.replace('_missing', '_not_directory'), kind === 'missing' ? message : `${relativePath}/ exists but is not a directory.`, target));
101
+ await inspectOptionalPlanningDirectory(inspection, storeRoot, 'specs', OPENSPEC_SPECS_DIR, 'openspec_specs_not_directory', 'openspec.specs');
102
+ const changesKind = await inspectOptionalPlanningDirectory(inspection, storeRoot, 'changes', OPENSPEC_CHANGES_DIR, 'openspec_changes_not_directory', 'openspec.changes');
103
+ if (changesKind === 'directory') {
104
+ await inspectOptionalPlanningDirectory(inspection, storeRoot, 'archive', OPENSPEC_ARCHIVE_DIR, 'openspec_archive_not_directory', 'openspec.archive');
105
+ }
106
+ else {
107
+ inspection.archive = { present: false };
103
108
  }
104
109
  inspection.healthy =
105
110
  inspection.present === true &&
106
111
  inspection.config.present === true &&
107
- inspection.specs.present === true &&
108
- inspection.changes.present === true &&
109
- inspection.archive.present === true;
112
+ inspection.diagnostics.length === 0;
110
113
  return inspection;
111
114
  }
112
115
  async function ensureDirectory(storeRoot, relativePath, ledger) {