@fission-ai/openspec 1.6.0 → 1.7.0

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 (175) hide show
  1. package/README.md +28 -1
  2. package/dist/cli/index.js +73 -7
  3. package/dist/commands/change.d.ts +0 -1
  4. package/dist/commands/change.js +90 -50
  5. package/dist/commands/config.js +14 -8
  6. package/dist/commands/doctor.js +9 -4
  7. package/dist/commands/feedback.js +74 -23
  8. package/dist/commands/schema.js +21 -11
  9. package/dist/commands/show.js +5 -2
  10. package/dist/commands/spec.js +20 -24
  11. package/dist/commands/validate.js +18 -3
  12. package/dist/commands/workflow/index.d.ts +1 -1
  13. package/dist/commands/workflow/index.js +1 -1
  14. package/dist/commands/workflow/instructions.d.ts +7 -1
  15. package/dist/commands/workflow/instructions.js +89 -6
  16. package/dist/commands/workflow/shared.d.ts +13 -2
  17. package/dist/commands/workflow/shared.js +34 -4
  18. package/dist/commands/workflow/status.js +7 -2
  19. package/dist/core/archive.d.ts +0 -1
  20. package/dist/core/archive.js +96 -33
  21. package/dist/core/artifact-graph/graph.d.ts +16 -0
  22. package/dist/core/artifact-graph/graph.js +30 -6
  23. package/dist/core/artifact-graph/instruction-loader.d.ts +27 -2
  24. package/dist/core/artifact-graph/instruction-loader.js +55 -7
  25. package/dist/core/artifact-graph/resolver.d.ts +14 -0
  26. package/dist/core/artifact-graph/resolver.js +34 -6
  27. package/dist/core/change-metadata/schema.d.ts +1 -0
  28. package/dist/core/change-metadata/schema.js +6 -0
  29. package/dist/core/change-status-policy.d.ts +1 -1
  30. package/dist/core/command-generation/adapters/amazon-q.d.ts +4 -0
  31. package/dist/core/command-generation/adapters/amazon-q.js +7 -1
  32. package/dist/core/command-generation/adapters/antigravity.js +2 -1
  33. package/dist/core/command-generation/adapters/auggie.js +2 -1
  34. package/dist/core/command-generation/adapters/bob.d.ts +5 -1
  35. package/dist/core/command-generation/adapters/bob.js +6 -5
  36. package/dist/core/command-generation/adapters/claude.js +1 -8
  37. package/dist/core/command-generation/adapters/codebuddy.js +3 -2
  38. package/dist/core/command-generation/adapters/continue.js +3 -2
  39. package/dist/core/command-generation/adapters/costrict.js +2 -1
  40. package/dist/core/command-generation/adapters/crush.js +5 -5
  41. package/dist/core/command-generation/adapters/cursor.js +2 -2
  42. package/dist/core/command-generation/adapters/devin.d.ts +19 -0
  43. package/dist/core/command-generation/adapters/devin.js +36 -0
  44. package/dist/core/command-generation/adapters/factory.js +2 -1
  45. package/dist/core/command-generation/adapters/gemini.js +37 -2
  46. package/dist/core/command-generation/adapters/github-copilot.js +2 -1
  47. package/dist/core/command-generation/adapters/iflow.js +5 -4
  48. package/dist/core/command-generation/adapters/index.d.ts +2 -2
  49. package/dist/core/command-generation/adapters/index.js +2 -2
  50. package/dist/core/command-generation/adapters/junie.js +2 -1
  51. package/dist/core/command-generation/adapters/kiro.js +2 -1
  52. package/dist/core/command-generation/adapters/lingma.js +5 -5
  53. package/dist/core/command-generation/adapters/oh-my-pi.d.ts +2 -2
  54. package/dist/core/command-generation/adapters/oh-my-pi.js +3 -5
  55. package/dist/core/command-generation/adapters/opencode.js +3 -5
  56. package/dist/core/command-generation/adapters/pi.d.ts +2 -2
  57. package/dist/core/command-generation/adapters/pi.js +3 -6
  58. package/dist/core/command-generation/adapters/qoder.js +5 -5
  59. package/dist/core/command-generation/adapters/qwen.d.ts +7 -3
  60. package/dist/core/command-generation/adapters/qwen.js +12 -7
  61. package/dist/core/command-generation/adapters/roocode.d.ts +4 -4
  62. package/dist/core/command-generation/adapters/roocode.js +4 -4
  63. package/dist/core/command-generation/adapters/trae.js +1 -21
  64. package/dist/core/command-generation/adapters/zcode.d.ts +16 -0
  65. package/dist/core/command-generation/adapters/zcode.js +33 -0
  66. package/dist/core/command-generation/generator.d.ts +8 -0
  67. package/dist/core/command-generation/generator.js +15 -1
  68. package/dist/core/command-generation/index.d.ts +1 -1
  69. package/dist/core/command-generation/index.js +1 -1
  70. package/dist/core/command-generation/invocation.d.ts +73 -0
  71. package/dist/core/command-generation/invocation.js +77 -0
  72. package/dist/core/command-generation/registry.js +4 -4
  73. package/dist/core/command-generation/types.d.ts +9 -2
  74. package/dist/core/command-generation/yaml.d.ts +20 -6
  75. package/dist/core/command-generation/yaml.js +31 -21
  76. package/dist/core/command-surface.d.ts +15 -0
  77. package/dist/core/command-surface.js +33 -0
  78. package/dist/core/completions/command-registry.js +8 -2
  79. package/dist/core/completions/generators/powershell-generator.js +15 -8
  80. package/dist/core/completions/installers/zsh-installer.d.ts +9 -0
  81. package/dist/core/completions/installers/zsh-installer.js +20 -4
  82. package/dist/core/config-prompts.d.ts +1 -1
  83. package/dist/core/config-prompts.js +14 -1
  84. package/dist/core/config-schema.d.ts +7 -1
  85. package/dist/core/config-schema.js +40 -1
  86. package/dist/core/config.d.ts +12 -0
  87. package/dist/core/config.js +21 -3
  88. package/dist/core/global-config.d.ts +5 -0
  89. package/dist/core/init.d.ts +35 -0
  90. package/dist/core/init.js +225 -39
  91. package/dist/core/legacy-cleanup.d.ts +59 -0
  92. package/dist/core/legacy-cleanup.js +249 -22
  93. package/dist/core/list.js +5 -7
  94. package/dist/core/migration.d.ts +71 -1
  95. package/dist/core/migration.js +294 -1
  96. package/dist/core/onboarding-commands.d.ts +30 -0
  97. package/dist/core/onboarding-commands.js +38 -0
  98. package/dist/core/parsers/change-parser.d.ts +17 -2
  99. package/dist/core/parsers/change-parser.js +33 -20
  100. package/dist/core/parsers/code-fence.d.ts +15 -0
  101. package/dist/core/parsers/code-fence.js +49 -0
  102. package/dist/core/parsers/markdown-parser.js +2 -1
  103. package/dist/core/parsers/requirement-blocks.d.ts +8 -0
  104. package/dist/core/parsers/requirement-blocks.js +60 -35
  105. package/dist/core/parsers/requirement-text.d.ts +1 -8
  106. package/dist/core/parsers/requirement-text.js +4 -42
  107. package/dist/core/parsers/spec-structure.js +3 -29
  108. package/dist/core/profile-sync-drift.d.ts +0 -8
  109. package/dist/core/profile-sync-drift.js +9 -45
  110. package/dist/core/project-config.d.ts +25 -6
  111. package/dist/core/project-config.js +77 -7
  112. package/dist/core/references.js +42 -4
  113. package/dist/core/relationship-health.d.ts +8 -0
  114. package/dist/core/relationship-health.js +13 -0
  115. package/dist/core/root-selection.d.ts +9 -6
  116. package/dist/core/root-selection.js +42 -5
  117. package/dist/core/shared/tool-detection.d.ts +25 -5
  118. package/dist/core/shared/tool-detection.js +105 -6
  119. package/dist/core/specs-apply.d.ts +8 -33
  120. package/dist/core/specs-apply.js +252 -157
  121. package/dist/core/store/git.d.ts +13 -0
  122. package/dist/core/store/git.js +24 -0
  123. package/dist/core/templates/workflows/apply-change.js +54 -4
  124. package/dist/core/templates/workflows/archive-change.js +166 -42
  125. package/dist/core/templates/workflows/bulk-archive-change.js +240 -63
  126. package/dist/core/templates/workflows/continue-change.js +32 -40
  127. package/dist/core/templates/workflows/explore.js +12 -0
  128. package/dist/core/templates/workflows/ff-change.js +46 -30
  129. package/dist/core/templates/workflows/new-change.js +2 -2
  130. package/dist/core/templates/workflows/onboard.js +4 -4
  131. package/dist/core/templates/workflows/propose.js +50 -32
  132. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  133. package/dist/core/templates/workflows/store-selection.js +1 -1
  134. package/dist/core/templates/workflows/sync-specs.js +172 -22
  135. package/dist/core/templates/workflows/update-change.js +20 -12
  136. package/dist/core/templates/workflows/verify-change.js +16 -10
  137. package/dist/core/update.d.ts +39 -5
  138. package/dist/core/update.js +288 -71
  139. package/dist/core/validation/constants.d.ts +4 -1
  140. package/dist/core/validation/constants.js +4 -1
  141. package/dist/core/validation/validator.d.ts +1 -7
  142. package/dist/core/validation/validator.js +92 -36
  143. package/dist/core/version-check.d.ts +152 -0
  144. package/dist/core/version-check.js +736 -0
  145. package/dist/core/view.js +12 -17
  146. package/dist/prompts/searchable-multi-select.js +1 -1
  147. package/dist/telemetry/index.d.ts +1 -1
  148. package/dist/telemetry/index.js +55 -33
  149. package/dist/ui/welcome-screen.d.ts +9 -1
  150. package/dist/ui/welcome-screen.js +91 -38
  151. package/dist/utils/change-metadata.d.ts +30 -0
  152. package/dist/utils/change-metadata.js +101 -7
  153. package/dist/utils/change-utils.d.ts +10 -4
  154. package/dist/utils/change-utils.js +20 -13
  155. package/dist/utils/command-references.d.ts +80 -6
  156. package/dist/utils/command-references.js +138 -7
  157. package/dist/utils/date.d.ts +8 -0
  158. package/dist/utils/date.js +13 -0
  159. package/dist/utils/index.d.ts +1 -1
  160. package/dist/utils/index.js +1 -1
  161. package/dist/utils/item-discovery.d.ts +19 -0
  162. package/dist/utils/item-discovery.js +30 -48
  163. package/dist/utils/shell-detection.d.ts +1 -1
  164. package/dist/utils/shell-detection.js +64 -11
  165. package/dist/utils/spec-discovery.d.ts +40 -0
  166. package/dist/utils/spec-discovery.js +102 -0
  167. package/package.json +14 -12
  168. package/schemas/spec-driven/schema.yaml +60 -5
  169. package/schemas/spec-driven/templates/design.md +2 -2
  170. package/schemas/spec-driven/templates/proposal.md +5 -1
  171. package/schemas/spec-driven/templates/spec.md +3 -0
  172. package/dist/core/command-generation/adapters/codex.d.ts +0 -16
  173. package/dist/core/command-generation/adapters/codex.js +0 -39
  174. package/dist/core/command-generation/adapters/windsurf.d.ts +0 -14
  175. package/dist/core/command-generation/adapters/windsurf.js +0 -38
@@ -1,32 +1,43 @@
1
1
  import path from 'path';
2
2
  import { FileSystemUtils } from './file-system.js';
3
3
  import { writeChangeMetadata, validateSchemaName } from './change-metadata.js';
4
+ import { formatLocalDate } from './date.js';
4
5
  import { readProjectConfig } from '../core/project-config.js';
6
+ import { isKebabId } from '../core/id.js';
5
7
  const DEFAULT_SCHEMA = 'spec-driven';
6
8
  /**
7
9
  * Validates that a change name follows kebab-case conventions.
8
10
  *
9
- * Valid names:
10
- * - Start with a lowercase letter
11
+ * Uses OpenSpec's shared kebab-id grammar (the same one store ids and change
12
+ * metadata ids use), so a change name may:
13
+ * - Start with a lowercase letter or a digit
11
14
  * - Contain only lowercase letters, numbers, and hyphens
12
- * - Do not start or end with a hyphen
13
- * - Do not contain consecutive hyphens
15
+ * - Not start or end with a hyphen
16
+ * - Not contain consecutive hyphens
17
+ *
18
+ * A leading digit is allowed so ordering conventions like `100-add-feature` or
19
+ * `00001-add-auth` work; archive already treats such prefixes as a supported
20
+ * convention (see ARCHIVE_DATE_PREFIX_PATTERN).
14
21
  *
15
22
  * @param name - The change name to validate
16
23
  * @returns Validation result with `valid: true` or `valid: false` with an error message
17
24
  *
18
25
  * @example
19
26
  * validateChangeName('add-auth') // { valid: true }
27
+ * validateChangeName('100-add-feature') // { valid: true }
20
28
  * validateChangeName('Add-Auth') // { valid: false, error: '...' }
21
29
  */
22
30
  export function validateChangeName(name) {
23
- // Pattern: starts with lowercase letter, followed by lowercase letters/numbers,
24
- // optionally followed by hyphen + lowercase letters/numbers (repeatable)
25
- const kebabCasePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
26
31
  if (!name) {
27
32
  return { valid: false, error: 'Change name cannot be empty' };
28
33
  }
29
- if (!kebabCasePattern.test(name)) {
34
+ // Filesystem directory components cap at 255 bytes and archive prepends a
35
+ // date prefix; bounding here turns the failure into a validation message
36
+ // instead of a raw ENAMETOOLONG from mkdir.
37
+ if (name.length > 200) {
38
+ return { valid: false, error: 'Change name is too long (200 characters max)' };
39
+ }
40
+ if (!isKebabId(name)) {
30
41
  // Provide specific error messages for common mistakes
31
42
  if (/[A-Z]/.test(name)) {
32
43
  return { valid: false, error: 'Change name must be lowercase (use kebab-case)' };
@@ -49,9 +60,6 @@ export function validateChangeName(name) {
49
60
  if (/[^a-z0-9-]/.test(name)) {
50
61
  return { valid: false, error: 'Change name can only contain lowercase letters, numbers, and hyphens' };
51
62
  }
52
- if (/^[0-9]/.test(name)) {
53
- return { valid: false, error: 'Change name must start with a letter' };
54
- }
55
63
  return { valid: false, error: 'Change name must follow kebab-case convention (e.g., add-auth, refactor-db)' };
56
64
  }
57
65
  return { valid: true };
@@ -127,10 +135,9 @@ export async function createChange(projectRoot, name, options = {}) {
127
135
  await FileSystemUtils.writeFile(configPath, `schema: ${defaultSchema}\n`);
128
136
  }
129
137
  // Write metadata file with schema and creation date
130
- const today = new Date().toISOString().split('T')[0];
131
138
  writeChangeMetadata(changeDir, {
132
139
  schema: schemaName,
133
- created: today,
140
+ created: formatLocalDate(),
134
141
  ...options.metadata,
135
142
  }, projectRoot);
136
143
  return { schema: schemaName, changeDir };
@@ -3,16 +3,90 @@
3
3
  *
4
4
  * Utilities for transforming command references to tool-specific formats.
5
5
  */
6
+ import type { CommandSurfaceCapability } from '../core/command-surface.js';
7
+ import type { CommandInvocation } from '../core/command-generation/invocation.js';
6
8
  /**
7
- * Transforms colon-based command references to hyphen-based format.
8
- * Converts `/opsx:` patterns to `/opsx-` for tools that use hyphen syntax.
9
+ * Rewrites the canonical `/opsx:<command>` references that command bodies and
10
+ * skill templates are authored with into the form one tool actually registers
11
+ * — `/opsx-<command>` for tools that name the command by filename,
12
+ * `@opsx-<command>` for Amazon Q's prompt library.
13
+ *
14
+ * Only known command ids are rewritten, matching how
15
+ * `transformToSkillReferences` leaves unrecognized references alone, so a
16
+ * mistyped or invented `/opsx:<something>` is left as written rather than
17
+ * silently reshaped into a command that does not exist either.
9
18
  *
10
19
  * @param text - The text containing command references
11
- * @returns Text with command references transformed to hyphen format
20
+ * @param invocation - The tool's invocation, from resolveCommandInvocation()
21
+ * @returns Text with command references spelled the tool's way
12
22
  *
13
23
  * @example
14
- * transformToHyphenCommands('/opsx:new') // returns '/opsx-new'
15
- * transformToHyphenCommands('Use /opsx:apply to implement') // returns 'Use /opsx-apply to implement'
24
+ * transformCommandInvocations('/opsx:new', { style: 'flat', prefix: '/' }) // '/opsx-new'
25
+ * transformCommandInvocations('/opsx:new', { style: 'flat', prefix: '@' }) // '@opsx-new'
26
+ */
27
+ export declare function transformCommandInvocations(text: string, invocation: CommandInvocation): string;
28
+ /**
29
+ * Transforms command references to skill references using the default `/`
30
+ * invocation prefix. Converts `/opsx:<command>` patterns to
31
+ * `/openspec-<skill>` so that generated skills do not reference commands
32
+ * that were never generated. Used for channels that are not tied to one
33
+ * tool (e.g. the skills.sh distribution); tool-targeted generation should
34
+ * go through getSkillReferenceTransformer instead.
35
+ *
36
+ * Unknown command references are left unchanged.
37
+ *
38
+ * @param text - The text containing command references
39
+ * @returns Text with command references transformed to skill references
40
+ *
41
+ * @example
42
+ * transformToSkillReferences('/opsx:apply') // returns '/openspec-apply-change'
43
+ * transformToSkillReferences('Use /opsx:archive next') // returns 'Use /openspec-archive-change next'
44
+ */
45
+ export declare function transformToSkillReferences(text: string): string;
46
+ /**
47
+ * Returns the skill-reference transformer for a specific tool, honoring the
48
+ * tool's documented skill invocation syntax (e.g. Kimi Code's
49
+ * `/skill:openspec-propose`). Falls back to the default `/openspec-*` form.
50
+ *
51
+ * @param toolId - The AI tool identifier (e.g. 'kimi', 'vibe')
52
+ * @returns A transformer converting `/opsx:*` references to skill invocations
53
+ */
54
+ export declare function getSkillReferenceTransformer(toolId: string): (text: string) => string;
55
+ /**
56
+ * Selects the command-reference transformer for a skill generation target.
57
+ *
58
+ * Skill references are used whenever the tool ends up without `/opsx:*`
59
+ * commands — because delivery is skills-only, because the tool has no command
60
+ * surface at all (capability 'none', e.g. Kimi Code or Mistral Vibe), or
61
+ * because the tool invokes skills directly and OpenSpec generates no command
62
+ * files for it (capability 'skills-invocable', i.e. Codex) — so those skills
63
+ * never point at commands that were not generated.
64
+ *
65
+ * When commands are generated, the spelling follows the tool's invocation: a
66
+ * `flat` adapter names the command by filename (`.cursor/commands/opsx-apply.md`
67
+ * → `/opsx-apply`), a `namespaced` adapter puts it in an `opsx/` directory
68
+ * (`.claude/commands/opsx/apply.md` → `/opsx:apply`), and a non-slash prefix
69
+ * wraps it further (`.amazonq/prompts/opsx-apply.md` → `@opsx-apply`). Passing
70
+ * the invocation in keeps this module free of a hand-maintained tool list —
71
+ * the list drifted and left 16 tools advertising commands their palettes never
72
+ * registered (#727, #1307).
73
+ *
74
+ * Devin is the one tool that takes skill references even though its commands
75
+ * are generated: only Devin Desktop reads `.devin/workflows/`, so a workflow
76
+ * reference is dead text for anyone on Devin Local, while the `/openspec-*`
77
+ * skills work on both agents. Under commands-only delivery there are no Devin
78
+ * skills to point at, so it falls through to the invocation rewrite below and
79
+ * gets the `/opsx-<id>` form its workflow filenames register.
80
+ *
81
+ * @param toolId - The AI tool identifier (e.g. 'claude', 'opencode', 'pi')
82
+ * @param delivery - The configured delivery mode
83
+ * @param capability - The tool's command surface capability
84
+ * @param invocation - How the tool's generated commands are invoked, from
85
+ * resolveCommandInvocation(); undefined for tools with no command
86
+ * adapter. Required rather than optional so a caller that forgets it
87
+ * fails to compile instead of silently getting the canonical form.
88
+ * @returns The transformer to pass to generateSkillContent, or undefined when
89
+ * the tool already answers to the canonical `/opsx:<id>`
16
90
  */
17
- export declare function transformToHyphenCommands(text: string): string;
91
+ export declare function getTransformerForTool(toolId: string, delivery: 'both' | 'skills' | 'commands', capability: CommandSurfaceCapability, invocation: CommandInvocation | undefined): ((text: string) => string) | undefined;
18
92
  //# sourceMappingURL=command-references.d.ts.map
@@ -3,18 +3,149 @@
3
3
  *
4
4
  * Utilities for transforming command references to tool-specific formats.
5
5
  */
6
+ // Value import of a pure, dependency-free helper: invocation.ts imports only
7
+ // `path` and a type, so this does not close the cycle the note above guards.
8
+ import { formatCommandInvocation, needsInvocationRewrite, } from '../core/command-generation/invocation.js';
6
9
  /**
7
- * Transforms colon-based command references to hyphen-based format.
8
- * Converts `/opsx:` patterns to `/opsx-` for tools that use hyphen syntax.
10
+ * Rewrites the canonical `/opsx:<command>` references that command bodies and
11
+ * skill templates are authored with into the form one tool actually registers
12
+ * — `/opsx-<command>` for tools that name the command by filename,
13
+ * `@opsx-<command>` for Amazon Q's prompt library.
14
+ *
15
+ * Only known command ids are rewritten, matching how
16
+ * `transformToSkillReferences` leaves unrecognized references alone, so a
17
+ * mistyped or invented `/opsx:<something>` is left as written rather than
18
+ * silently reshaped into a command that does not exist either.
9
19
  *
10
20
  * @param text - The text containing command references
11
- * @returns Text with command references transformed to hyphen format
21
+ * @param invocation - The tool's invocation, from resolveCommandInvocation()
22
+ * @returns Text with command references spelled the tool's way
12
23
  *
13
24
  * @example
14
- * transformToHyphenCommands('/opsx:new') // returns '/opsx-new'
15
- * transformToHyphenCommands('Use /opsx:apply to implement') // returns 'Use /opsx-apply to implement'
25
+ * transformCommandInvocations('/opsx:new', { style: 'flat', prefix: '/' }) // '/opsx-new'
26
+ * transformCommandInvocations('/opsx:new', { style: 'flat', prefix: '@' }) // '@opsx-new'
27
+ */
28
+ export function transformCommandInvocations(text, invocation) {
29
+ return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId) => commandId in COMMAND_TO_SKILL_NAME
30
+ ? formatCommandInvocation(invocation, commandId)
31
+ : match);
32
+ }
33
+ /**
34
+ * Maps command short names to their skill names.
35
+ * Keep in sync with WORKFLOW_TO_SKILL_DIR, which exists in both
36
+ * src/core/profile-sync-drift.ts (exported) and src/core/init.ts (local copy).
37
+ */
38
+ const COMMAND_TO_SKILL_NAME = {
39
+ 'explore': 'openspec-explore',
40
+ 'new': 'openspec-new-change',
41
+ 'continue': 'openspec-continue-change',
42
+ 'apply': 'openspec-apply-change',
43
+ 'update': 'openspec-update-change',
44
+ 'ff': 'openspec-ff-change',
45
+ 'sync': 'openspec-sync-specs',
46
+ 'archive': 'openspec-archive-change',
47
+ 'bulk-archive': 'openspec-bulk-archive-change',
48
+ 'verify': 'openspec-verify-change',
49
+ 'onboard': 'openspec-onboard',
50
+ 'propose': 'openspec-propose',
51
+ };
52
+ /**
53
+ * Tools whose skill invocation uses a non-default prefix. The default is `/`
54
+ * (e.g. `/openspec-propose`); Kimi Code invokes skills as `/skill:<name>` and
55
+ * Codex CLI as `$<name>` — a `/<name>` form Codex does not recognize
56
+ * (see docs/supported-tools.md).
57
+ */
58
+ const SKILL_INVOCATION_PREFIX = {
59
+ kimi: '/skill:',
60
+ codex: '$',
61
+ };
62
+ function replaceCommandsWithSkillReferences(text, prefix) {
63
+ return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId) => {
64
+ const skillName = COMMAND_TO_SKILL_NAME[commandId];
65
+ return skillName === undefined ? match : `${prefix}${skillName}`;
66
+ });
67
+ }
68
+ /**
69
+ * Transforms command references to skill references using the default `/`
70
+ * invocation prefix. Converts `/opsx:<command>` patterns to
71
+ * `/openspec-<skill>` so that generated skills do not reference commands
72
+ * that were never generated. Used for channels that are not tied to one
73
+ * tool (e.g. the skills.sh distribution); tool-targeted generation should
74
+ * go through getSkillReferenceTransformer instead.
75
+ *
76
+ * Unknown command references are left unchanged.
77
+ *
78
+ * @param text - The text containing command references
79
+ * @returns Text with command references transformed to skill references
80
+ *
81
+ * @example
82
+ * transformToSkillReferences('/opsx:apply') // returns '/openspec-apply-change'
83
+ * transformToSkillReferences('Use /opsx:archive next') // returns 'Use /openspec-archive-change next'
84
+ */
85
+ export function transformToSkillReferences(text) {
86
+ return replaceCommandsWithSkillReferences(text, '/');
87
+ }
88
+ /**
89
+ * Returns the skill-reference transformer for a specific tool, honoring the
90
+ * tool's documented skill invocation syntax (e.g. Kimi Code's
91
+ * `/skill:openspec-propose`). Falls back to the default `/openspec-*` form.
92
+ *
93
+ * @param toolId - The AI tool identifier (e.g. 'kimi', 'vibe')
94
+ * @returns A transformer converting `/opsx:*` references to skill invocations
95
+ */
96
+ export function getSkillReferenceTransformer(toolId) {
97
+ const prefix = SKILL_INVOCATION_PREFIX[toolId];
98
+ if (prefix === undefined) {
99
+ return transformToSkillReferences;
100
+ }
101
+ return (text) => replaceCommandsWithSkillReferences(text, prefix);
102
+ }
103
+ /**
104
+ * Selects the command-reference transformer for a skill generation target.
105
+ *
106
+ * Skill references are used whenever the tool ends up without `/opsx:*`
107
+ * commands — because delivery is skills-only, because the tool has no command
108
+ * surface at all (capability 'none', e.g. Kimi Code or Mistral Vibe), or
109
+ * because the tool invokes skills directly and OpenSpec generates no command
110
+ * files for it (capability 'skills-invocable', i.e. Codex) — so those skills
111
+ * never point at commands that were not generated.
112
+ *
113
+ * When commands are generated, the spelling follows the tool's invocation: a
114
+ * `flat` adapter names the command by filename (`.cursor/commands/opsx-apply.md`
115
+ * → `/opsx-apply`), a `namespaced` adapter puts it in an `opsx/` directory
116
+ * (`.claude/commands/opsx/apply.md` → `/opsx:apply`), and a non-slash prefix
117
+ * wraps it further (`.amazonq/prompts/opsx-apply.md` → `@opsx-apply`). Passing
118
+ * the invocation in keeps this module free of a hand-maintained tool list —
119
+ * the list drifted and left 16 tools advertising commands their palettes never
120
+ * registered (#727, #1307).
121
+ *
122
+ * Devin is the one tool that takes skill references even though its commands
123
+ * are generated: only Devin Desktop reads `.devin/workflows/`, so a workflow
124
+ * reference is dead text for anyone on Devin Local, while the `/openspec-*`
125
+ * skills work on both agents. Under commands-only delivery there are no Devin
126
+ * skills to point at, so it falls through to the invocation rewrite below and
127
+ * gets the `/opsx-<id>` form its workflow filenames register.
128
+ *
129
+ * @param toolId - The AI tool identifier (e.g. 'claude', 'opencode', 'pi')
130
+ * @param delivery - The configured delivery mode
131
+ * @param capability - The tool's command surface capability
132
+ * @param invocation - How the tool's generated commands are invoked, from
133
+ * resolveCommandInvocation(); undefined for tools with no command
134
+ * adapter. Required rather than optional so a caller that forgets it
135
+ * fails to compile instead of silently getting the canonical form.
136
+ * @returns The transformer to pass to generateSkillContent, or undefined when
137
+ * the tool already answers to the canonical `/opsx:<id>`
16
138
  */
17
- export function transformToHyphenCommands(text) {
18
- return text.replace(/\/opsx:/g, '/opsx-');
139
+ export function getTransformerForTool(toolId, delivery, capability, invocation) {
140
+ if (delivery === 'skills' || capability !== 'adapter-backed') {
141
+ return getSkillReferenceTransformer(toolId);
142
+ }
143
+ if (toolId === 'devin' && delivery === 'both') {
144
+ return getSkillReferenceTransformer(toolId);
145
+ }
146
+ if (invocation !== undefined && needsInvocationRewrite(invocation)) {
147
+ return (text) => transformCommandInvocations(text, invocation);
148
+ }
149
+ return undefined;
19
150
  }
20
151
  //# sourceMappingURL=command-references.js.map
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Formats a date using the effective local time zone of the Node.js process.
3
+ *
4
+ * The result is locale-independent and suitable for date-only metadata and
5
+ * path prefixes.
6
+ */
7
+ export declare function formatLocalDate(date?: Date): string;
8
+ //# sourceMappingURL=date.d.ts.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Formats a date using the effective local time zone of the Node.js process.
3
+ *
4
+ * The result is locale-independent and suitable for date-only metadata and
5
+ * path prefixes.
6
+ */
7
+ export function formatLocalDate(date = new Date()) {
8
+ const year = date.getFullYear();
9
+ const month = String(date.getMonth() + 1).padStart(2, '0');
10
+ const day = String(date.getDate()).padStart(2, '0');
11
+ return `${year}-${month}-${day}`;
12
+ }
13
+ //# sourceMappingURL=date.js.map
@@ -2,5 +2,5 @@ export { validateChangeName, createChange } from './change-utils.js';
2
2
  export type { ValidationResult, CreateChangeOptions } from './change-utils.js';
3
3
  export { readChangeMetadata, writeChangeMetadata, resolveSchemaForChange, validateSchemaName, ChangeMetadataError, } from './change-metadata.js';
4
4
  export { FileSystemUtils, removeMarkerBlock } from './file-system.js';
5
- export { transformToHyphenCommands } from './command-references.js';
5
+ export { transformCommandInvocations, transformToSkillReferences, getSkillReferenceTransformer, getTransformerForTool, } from './command-references.js';
6
6
  //# sourceMappingURL=index.d.ts.map
@@ -5,5 +5,5 @@ export { readChangeMetadata, writeChangeMetadata, resolveSchemaForChange, valida
5
5
  // File system utilities
6
6
  export { FileSystemUtils, removeMarkerBlock } from './file-system.js';
7
7
  // Command reference utilities
8
- export { transformToHyphenCommands } from './command-references.js';
8
+ export { transformCommandInvocations, transformToSkillReferences, getSkillReferenceTransformer, getTransformerForTool, } from './command-references.js';
9
9
  //# sourceMappingURL=index.js.map
@@ -1,4 +1,23 @@
1
+ /**
2
+ * Returns the ids of active changes: every directory under openspec/changes/
3
+ * except the archive and hidden directories.
4
+ *
5
+ * A change is resolved by its directory alone - the same rule `list`,
6
+ * `status`, `instructions` and `validate` use (`getAvailableChanges`).
7
+ * Requiring proposal.md here made `openspec show` and shell completion miss
8
+ * changes those commands resolve: `openspec new change <name>` scaffolds only
9
+ * `.openspec.yaml`, and a custom schema need not define a proposal artifact at
10
+ * all (#1161).
11
+ */
1
12
  export declare function getActiveChangeIds(root?: string): Promise<string[]>;
2
13
  export declare function getSpecIds(root?: string): Promise<string[]>;
14
+ /**
15
+ * Returns the ids of archived changes: every directory under
16
+ * openspec/changes/archive/ except hidden directories.
17
+ *
18
+ * Resolved by directory for the same reason as `getActiveChangeIds`: a change
19
+ * archived from a schema without a proposal artifact has no proposal.md, and
20
+ * gating on it hid those entries from shell completion.
21
+ */
3
22
  export declare function getArchivedChangeIds(root?: string): Promise<string[]>;
4
23
  //# sourceMappingURL=item-discovery.d.ts.map
@@ -1,23 +1,25 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
+ import { discoverSpecFiles } from './spec-discovery.js';
4
+ /**
5
+ * Returns the ids of active changes: every directory under openspec/changes/
6
+ * except the archive and hidden directories.
7
+ *
8
+ * A change is resolved by its directory alone - the same rule `list`,
9
+ * `status`, `instructions` and `validate` use (`getAvailableChanges`).
10
+ * Requiring proposal.md here made `openspec show` and shell completion miss
11
+ * changes those commands resolve: `openspec new change <name>` scaffolds only
12
+ * `.openspec.yaml`, and a custom schema need not define a proposal artifact at
13
+ * all (#1161).
14
+ */
3
15
  export async function getActiveChangeIds(root = process.cwd()) {
4
16
  const changesPath = path.join(root, 'openspec', 'changes');
5
17
  try {
6
18
  const entries = await fs.readdir(changesPath, { withFileTypes: true });
7
- const result = [];
8
- for (const entry of entries) {
9
- if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'archive')
10
- continue;
11
- const proposalPath = path.join(changesPath, entry.name, 'proposal.md');
12
- try {
13
- await fs.access(proposalPath);
14
- result.push(entry.name);
15
- }
16
- catch {
17
- // skip directories without proposal.md
18
- }
19
- }
20
- return result.sort();
19
+ return entries
20
+ .filter((entry) => entry.isDirectory() && entry.name !== 'archive' && !entry.name.startsWith('.'))
21
+ .map((entry) => entry.name)
22
+ .sort();
21
23
  }
22
24
  catch {
23
25
  return [];
@@ -25,45 +27,25 @@ export async function getActiveChangeIds(root = process.cwd()) {
25
27
  }
26
28
  export async function getSpecIds(root = process.cwd()) {
27
29
  const specsPath = path.join(root, 'openspec', 'specs');
28
- const result = [];
29
- try {
30
- const entries = await fs.readdir(specsPath, { withFileTypes: true });
31
- for (const entry of entries) {
32
- if (!entry.isDirectory() || entry.name.startsWith('.'))
33
- continue;
34
- const specFile = path.join(specsPath, entry.name, 'spec.md');
35
- try {
36
- await fs.access(specFile);
37
- result.push(entry.name);
38
- }
39
- catch {
40
- // ignore
41
- }
42
- }
43
- }
44
- catch {
45
- // ignore
46
- }
47
- return result.sort();
30
+ const discovered = await discoverSpecFiles(specsPath);
31
+ return discovered.map((spec) => spec.id);
48
32
  }
33
+ /**
34
+ * Returns the ids of archived changes: every directory under
35
+ * openspec/changes/archive/ except hidden directories.
36
+ *
37
+ * Resolved by directory for the same reason as `getActiveChangeIds`: a change
38
+ * archived from a schema without a proposal artifact has no proposal.md, and
39
+ * gating on it hid those entries from shell completion.
40
+ */
49
41
  export async function getArchivedChangeIds(root = process.cwd()) {
50
42
  const archivePath = path.join(root, 'openspec', 'changes', 'archive');
51
43
  try {
52
44
  const entries = await fs.readdir(archivePath, { withFileTypes: true });
53
- const result = [];
54
- for (const entry of entries) {
55
- if (!entry.isDirectory() || entry.name.startsWith('.'))
56
- continue;
57
- const proposalPath = path.join(archivePath, entry.name, 'proposal.md');
58
- try {
59
- await fs.access(proposalPath);
60
- result.push(entry.name);
61
- }
62
- catch {
63
- // skip directories without proposal.md
64
- }
65
- }
66
- return result.sort();
45
+ return entries
46
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
47
+ .map((entry) => entry.name)
48
+ .sort();
67
49
  }
68
50
  catch {
69
51
  return [];
@@ -12,7 +12,7 @@ export interface ShellDetectionResult {
12
12
  detected: string | undefined;
13
13
  }
14
14
  /**
15
- * Detects the current user's shell based on environment variables
15
+ * Detects the current user's shell based on the parent process and environment
16
16
  *
17
17
  * @returns Detection result with supported shell and raw detected name
18
18
  */
@@ -1,21 +1,74 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  /**
2
- * Detects the current user's shell based on environment variables
3
+ * Map a raw shell name/path to a supported shell, if any.
4
+ */
5
+ function matchSupportedShell(name) {
6
+ // Match the executable basename exactly so lookalikes such as `fish-lsp` or
7
+ // `bash-language-server` don't get mistaken for the shell itself. Login
8
+ // shells report a leading dash (e.g. `-zsh`), so strip it first.
9
+ const executable = name.trim().toLowerCase().split('/').pop()?.replace(/^-/, '');
10
+ if (executable === 'zsh')
11
+ return 'zsh';
12
+ if (executable === 'bash')
13
+ return 'bash';
14
+ if (executable === 'fish')
15
+ return 'fish';
16
+ return undefined;
17
+ }
18
+ /**
19
+ * Detect the interactive shell from the parent process.
20
+ *
21
+ * `process.env.SHELL` is only the login shell, so users whose interactive shell
22
+ * differs from it (e.g. running fish while their login shell is bash) are
23
+ * misdetected. Inspecting the parent process reflects the shell that actually
24
+ * launched openspec. POSIX-only and best-effort — any failure returns undefined
25
+ * so the caller falls back to `$SHELL`.
26
+ *
27
+ * @returns The supported shell running as the parent process, or undefined
28
+ */
29
+ function detectShellFromParentProcess() {
30
+ // `ps` is POSIX-only; Windows shells are handled via PSModulePath/COMSPEC.
31
+ if (process.platform === 'win32') {
32
+ return undefined;
33
+ }
34
+ const ppid = process.ppid;
35
+ if (!ppid || ppid <= 1) {
36
+ return undefined;
37
+ }
38
+ try {
39
+ const comm = execFileSync('ps', ['-p', String(ppid), '-o', 'comm='], {
40
+ encoding: 'utf8',
41
+ timeout: 1000,
42
+ }).trim();
43
+ if (!comm) {
44
+ return undefined;
45
+ }
46
+ // Only trust the parent process when it maps to a supported shell; an
47
+ // unrelated parent (node, npm, sudo, a pager) falls through to `$SHELL`.
48
+ return matchSupportedShell(comm);
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ /**
55
+ * Detects the current user's shell based on the parent process and environment
3
56
  *
4
57
  * @returns Detection result with supported shell and raw detected name
5
58
  */
6
59
  export function detectShell() {
7
- // Try SHELL environment variable first (Unix-like systems)
60
+ // Prefer the actual running shell (parent process) over `$SHELL`, which only
61
+ // reflects the login shell and misses users whose interactive shell differs.
62
+ const parentShell = detectShellFromParentProcess();
63
+ if (parentShell) {
64
+ return { shell: parentShell, detected: parentShell };
65
+ }
66
+ // Try SHELL environment variable next (Unix-like systems)
8
67
  const shellPath = process.env.SHELL;
9
68
  if (shellPath) {
10
- const shellName = shellPath.toLowerCase();
11
- if (shellName.includes('zsh')) {
12
- return { shell: 'zsh', detected: 'zsh' };
13
- }
14
- if (shellName.includes('bash')) {
15
- return { shell: 'bash', detected: 'bash' };
16
- }
17
- if (shellName.includes('fish')) {
18
- return { shell: 'fish', detected: 'fish' };
69
+ const supported = matchSupportedShell(shellPath);
70
+ if (supported) {
71
+ return { shell: supported, detected: supported };
19
72
  }
20
73
  // Shell detected but not supported
21
74
  // Extract shell name from path (e.g., /bin/tcsh -> tcsh)
@@ -0,0 +1,40 @@
1
+ export interface DiscoveredSpec {
2
+ /** Spec id relative to the specs root, forward-slash separated on every platform (e.g. "web" or "platform/session-layout"). */
3
+ id: string;
4
+ /** Path to the spec.md file (absolute if the specs root is absolute). */
5
+ specFile: string;
6
+ }
7
+ /**
8
+ * Recursively discover every `spec.md` under a specs root, so both the flat
9
+ * `specs/<id>/spec.md` layout and nested `specs/<area>/<id>/spec.md` layouts
10
+ * are found (#1353). A `spec.md` sitting directly in the root is ignored,
11
+ * matching the historical requirement that specs live in a capability folder.
12
+ * Dot-directories are skipped and symlinked directories are not followed.
13
+ * A symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the artifact
14
+ * graph's globs both count it as content, so dropping it here would silently
15
+ * lose the delta on archive; a dangling link is skipped. Results are sorted
16
+ * by id for deterministic output.
17
+ *
18
+ * A missing root (ENOENT) yields an empty list, but any other read failure
19
+ * (EACCES, EIO, ...) is thrown rather than swallowed: since this feeds the
20
+ * archive/apply merge path, silently dropping an unreadable capability would
21
+ * recreate the exact data-loss class #1353 is closing.
22
+ */
23
+ export declare function discoverSpecFiles(specsRoot: string): Promise<DiscoveredSpec[]>;
24
+ /**
25
+ * True when any regular non-dot file exists anywhere under the given
26
+ * directory. Used by validate/archive to detect content under a change's
27
+ * specs/ that contradicts a declared skip_specs marker - including files that
28
+ * discoverSpecFiles ignores (a root spec.md, stray non-spec.md notes), since
29
+ * anything there would be silently dropped or misread while the change claims
30
+ * to have nothing. Dot entries (.DS_Store, .gitkeep, dot-directories) are
31
+ * skipped to match discoverSpecFiles - they are invisible to every other
32
+ * code path, so they must not count as spec content. Symlinks DO count
33
+ * (without being followed): the artifact graph's globs follow them, so a
34
+ * symlinked spec would read as existing content while the change claims to
35
+ * have none - it contradicts the marker like any regular file. A missing
36
+ * directory returns false; other read failures are thrown for the caller to
37
+ * decide.
38
+ */
39
+ export declare function hasAnyFileUnder(dirPath: string): Promise<boolean>;
40
+ //# sourceMappingURL=spec-discovery.d.ts.map