@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
package/dist/core/view.js CHANGED
@@ -3,6 +3,7 @@ import * as path from 'path';
3
3
  import chalk from 'chalk';
4
4
  import { getTaskProgressForChange } from '../utils/task-progress.js';
5
5
  import { MarkdownParser } from './parsers/markdown-parser.js';
6
+ import { discoverSpecFiles } from '../utils/spec-discovery.js';
6
7
  export class ViewCommand {
7
8
  async execute(targetPath = '.') {
8
9
  const openspecDir = path.join(targetPath, 'openspec');
@@ -106,23 +107,17 @@ export class ViewCommand {
106
107
  return [];
107
108
  }
108
109
  const specs = [];
109
- const entries = fs.readdirSync(specsDir, { withFileTypes: true });
110
- for (const entry of entries) {
111
- if (entry.isDirectory()) {
112
- const specFile = path.join(specsDir, entry.name, 'spec.md');
113
- if (fs.existsSync(specFile)) {
114
- try {
115
- const content = fs.readFileSync(specFile, 'utf-8');
116
- const parser = new MarkdownParser(content);
117
- const spec = parser.parseSpec(entry.name);
118
- const requirementCount = spec.requirements.length;
119
- specs.push({ name: entry.name, requirementCount });
120
- }
121
- catch (error) {
122
- // If spec cannot be parsed, include with 0 count
123
- specs.push({ name: entry.name, requirementCount: 0 });
124
- }
125
- }
110
+ for (const { id, specFile } of await discoverSpecFiles(specsDir)) {
111
+ try {
112
+ const content = fs.readFileSync(specFile, 'utf-8');
113
+ const parser = new MarkdownParser(content);
114
+ const spec = parser.parseSpec(id);
115
+ const requirementCount = spec.requirements.length;
116
+ specs.push({ name: id, requirementCount });
117
+ }
118
+ catch (error) {
119
+ // If spec cannot be parsed, include with 0 count
120
+ specs.push({ name: id, requirementCount: 0 });
126
121
  }
127
122
  }
128
123
  return specs;
@@ -113,7 +113,7 @@ async function createSearchableMultiSelect() {
113
113
  const actualIndex = startIndex + i;
114
114
  const isActive = actualIndex === cursor;
115
115
  const selected = selectedSet.has(item.value);
116
- const icon = selected ? chalk.green('◉') : chalk.dim('○');
116
+ const icon = selected ? chalk.green('[x]') : chalk.dim('[ ]');
117
117
  const arrow = isActive ? chalk.cyan('›') : ' ';
118
118
  const name = isActive ? chalk.cyan(item.name) : item.name;
119
119
  const isRefresh = selected && item.configured;
@@ -24,7 +24,7 @@ export declare function trackCommand(commandName: string, version: string): Prom
24
24
  */
25
25
  export declare function maybeShowTelemetryNotice(): Promise<void>;
26
26
  /**
27
- * Shutdown the PostHog client and flush pending events.
27
+ * Flush pending telemetry events.
28
28
  * Call this before CLI exit.
29
29
  */
30
30
  export declare function shutdown(): Promise<void>;
@@ -7,8 +7,17 @@
7
7
  * - Opt-out via OPENSPEC_TELEMETRY=0 or DO_NOT_TRACK=1
8
8
  * - Auto-disabled in CI environments
9
9
  * - Anonymous ID is a random UUID with no relation to the user
10
+ *
11
+ * Events are sent with a plain fetch to PostHog's stable public `/batch/`
12
+ * endpoint — the same one posthog-node used — instead of through the SDK.
13
+ * The SDK's only remaining job here was the wire format: every reliability
14
+ * knob was already forced to "send one event immediately, time-bounded,
15
+ * never retry, never throw". Carrying `posthog-node` for that shipped its
16
+ * fast-moving transitive tree (`@posthog/core`, `@posthog/types`, multiple
17
+ * releases per day) to every downstream consumer, where supply-chain age
18
+ * policies such as pnpm's `minimumReleaseAge` rejected the freshly published
19
+ * versions and broke installs (#1390).
10
20
  */
11
- import { PostHog } from 'posthog-node';
12
21
  import { randomUUID } from 'crypto';
13
22
  import { getTelemetryConfig, updateTelemetryConfig } from './config.js';
14
23
  // PostHog API key - public key for client-side analytics
@@ -17,11 +26,23 @@ const POSTHOG_API_KEY = 'phc_Hthu8YvaIJ9QaFKyTG4TbVwkbd5ktcAFzVTKeMmoW2g';
17
26
  // Using reverse proxy to avoid ad blockers and keep traffic on our domain
18
27
  const POSTHOG_HOST = 'https://edge.openspec.dev';
19
28
  const TELEMETRY_REQUEST_TIMEOUT_MS = 1000;
20
- let posthogClient = null;
21
29
  let anonymousId = null;
30
+ /**
31
+ * Requests started by trackCommand and not yet settled, so shutdown can
32
+ * flush them before the process exits. Each request is individually
33
+ * time-bounded, so awaiting them cannot stall exit for more than the
34
+ * request timeout.
35
+ */
36
+ const pendingEvents = new Set();
22
37
  async function safeTelemetryFetch(url, options) {
23
38
  try {
24
39
  const response = await fetch(url, options);
40
+ // Telemetry never reads the body, but undici keeps the connection
41
+ // occupied until the body is consumed or canceled — dispose of it on
42
+ // every path so no socket outlives shutdown().
43
+ if (response.body) {
44
+ await response.body.cancel();
45
+ }
25
46
  if (response.ok) {
26
47
  return response;
27
48
  }
@@ -75,24 +96,30 @@ export async function getOrCreateAnonymousId() {
75
96
  return anonymousId;
76
97
  }
77
98
  /**
78
- * Get the PostHog client instance.
79
- * Creates it on first call with CLI-optimized settings.
99
+ * Send one capture event to PostHog's batch endpoint. Fire-and-forget:
100
+ * bounded by the request timeout, never throws, never retries.
80
101
  */
81
- function getClient() {
82
- if (!posthogClient) {
83
- posthogClient = new PostHog(POSTHOG_API_KEY, {
84
- host: POSTHOG_HOST,
85
- flushAt: 1, // Send immediately, don't batch
86
- flushInterval: 0, // No timer-based flushing
87
- fetchRetryCount: 0,
88
- requestTimeout: TELEMETRY_REQUEST_TIMEOUT_MS,
89
- preloadFeatureFlags: false,
90
- disableRemoteConfig: true,
91
- disableSurveys: true,
92
- fetch: safeTelemetryFetch,
93
- });
94
- }
95
- return posthogClient;
102
+ function sendEvent(distinctId, event, properties) {
103
+ const body = JSON.stringify({
104
+ api_key: POSTHOG_API_KEY,
105
+ batch: [
106
+ {
107
+ type: 'capture',
108
+ event,
109
+ distinct_id: distinctId,
110
+ properties,
111
+ timestamp: new Date().toISOString(),
112
+ },
113
+ ],
114
+ });
115
+ const request = safeTelemetryFetch(`${POSTHOG_HOST}/batch/`, {
116
+ method: 'POST',
117
+ headers: { 'content-type': 'application/json' },
118
+ body,
119
+ signal: AbortSignal.timeout(TELEMETRY_REQUEST_TIMEOUT_MS),
120
+ }).then(() => undefined);
121
+ pendingEvents.add(request);
122
+ void request.finally(() => pendingEvents.delete(request));
96
123
  }
97
124
  /**
98
125
  * Track a command execution.
@@ -106,16 +133,11 @@ export async function trackCommand(commandName, version) {
106
133
  }
107
134
  try {
108
135
  const userId = await getOrCreateAnonymousId();
109
- const client = getClient();
110
- client.capture({
111
- distinctId: userId,
112
- event: 'command_executed',
113
- properties: {
114
- command: commandName,
115
- version: version,
116
- surface: 'cli',
117
- $ip: null, // Explicitly disable IP tracking
118
- },
136
+ sendEvent(userId, 'command_executed', {
137
+ command: commandName,
138
+ version: version,
139
+ surface: 'cli',
140
+ $ip: null, // Explicitly disable IP tracking
119
141
  });
120
142
  }
121
143
  catch {
@@ -144,21 +166,21 @@ export async function maybeShowTelemetryNotice() {
144
166
  }
145
167
  }
146
168
  /**
147
- * Shutdown the PostHog client and flush pending events.
169
+ * Flush pending telemetry events.
148
170
  * Call this before CLI exit.
149
171
  */
150
172
  export async function shutdown() {
151
- if (!posthogClient) {
173
+ if (pendingEvents.size === 0) {
152
174
  return;
153
175
  }
154
176
  try {
155
- await posthogClient.shutdown();
177
+ await Promise.allSettled([...pendingEvents]);
156
178
  }
157
179
  catch {
158
180
  // Silent failure - telemetry should never break CLI exit
159
181
  }
160
182
  finally {
161
- posthogClient = null;
183
+ pendingEvents.clear();
162
184
  }
163
185
  }
164
186
  //# sourceMappingURL=index.js.map
@@ -2,9 +2,17 @@
2
2
  * Animated welcome screen for the experimental artifact workflow setup.
3
3
  * Shows side-by-side layout with animated ASCII art on left and welcome text on right.
4
4
  */
5
+ /**
6
+ * Best-effort check of the OS-level reduced-motion preference (#722).
7
+ * Any lookup failure (missing binary, unset key, timeout) means
8
+ * "no preference detected" and animation stays enabled.
9
+ */
10
+ export declare function prefersReducedMotion(platform?: NodeJS.Platform): boolean;
5
11
  /**
6
12
  * Shows the animated welcome screen.
7
13
  * Returns when user presses Enter.
8
14
  */
9
- export declare function showWelcomeScreen(): Promise<void>;
15
+ export declare function showWelcomeScreen(workflows: readonly string[], options?: {
16
+ animate?: boolean;
17
+ }): Promise<void>;
10
18
  //# sourceMappingURL=welcome-screen.d.ts.map
@@ -3,7 +3,9 @@
3
3
  * Shows side-by-side layout with animated ASCII art on left and welcome text on right.
4
4
  */
5
5
  import chalk from 'chalk';
6
+ import { execFileSync, } from 'node:child_process';
6
7
  import { WELCOME_ANIMATION } from './ascii-patterns.js';
8
+ import { getOnboardingCommands } from '../core/onboarding-commands.js';
7
9
  // Minimum terminal width for side-by-side layout
8
10
  const MIN_WIDTH = 60;
9
11
  // Width of the ASCII art column (with padding)
@@ -11,20 +13,35 @@ const ART_COLUMN_WIDTH = 24;
11
13
  /**
12
14
  * Welcome text content (right column)
13
15
  */
14
- function getWelcomeText() {
16
+ function getWelcomeText(workflows) {
17
+ const onboardingCommands = getOnboardingCommands(workflows);
18
+ const quickStart = [];
19
+ if (onboardingCommands.length > 0) {
20
+ const commandWidth = Math.max(...onboardingCommands.map((c) => c.command.length));
21
+ quickStart.push(chalk.white('Quick start after setup:'));
22
+ for (const { command, description } of onboardingCommands) {
23
+ quickStart.push(` ${chalk.yellow(command.padEnd(commandWidth + 1))} ${chalk.dim(description)}`);
24
+ }
25
+ // These are the canonical names. How each tool spells them differs
26
+ // (/opsx-propose, @opsx-propose, $openspec-propose ...) and cannot be known
27
+ // until tools are picked, one prompt later — so flag it rather than let the
28
+ // canonical form read as the literal thing to type. "Getting started"
29
+ // prints the real spelling once the selection is known.
30
+ quickStart.push(chalk.dim(' (spelling varies by tool)'));
31
+ quickStart.push('');
32
+ }
15
33
  return [
16
34
  chalk.white.bold('Welcome to OpenSpec'),
17
35
  chalk.dim('A lightweight spec-driven framework'),
18
36
  '',
19
37
  chalk.white('This setup will configure:'),
20
38
  chalk.dim(' • Agent Skills for AI tools'),
21
- chalk.dim(' • /opsx:* slash commands'),
22
- '',
23
- chalk.white('Quick start after setup:'),
24
- ` ${chalk.yellow('/opsx:new')} ${chalk.dim('Create a change')}`,
25
- ` ${chalk.yellow('/opsx:continue')} ${chalk.dim('Next artifact')}`,
26
- ` ${chalk.yellow('/opsx:apply')} ${chalk.dim('Implement tasks')}`,
39
+ // Not "opsx slash commands": this screen runs before tool selection, and
40
+ // skills-only tools (Codex, Kimi Code, ...) correctly get no command files
41
+ // at all. The exact spelling per tool is printed in "Getting started".
42
+ chalk.dim(' • Workflow commands, if supported'),
27
43
  '',
44
+ ...quickStart,
28
45
  chalk.cyan('Press Enter to select tools...'),
29
46
  ];
30
47
  }
@@ -46,6 +63,36 @@ function renderFrame(artLines, textLines) {
46
63
  }
47
64
  return lines.join('\n');
48
65
  }
66
+ const REDUCED_MOTION_EXEC_OPTIONS = {
67
+ encoding: 'utf8',
68
+ timeout: 500,
69
+ // SIGKILL so a wedged lookup can never outlive the timeout and stall init.
70
+ killSignal: 'SIGKILL',
71
+ stdio: ['ignore', 'pipe', 'ignore'],
72
+ };
73
+ /**
74
+ * Best-effort check of the OS-level reduced-motion preference (#722).
75
+ * Any lookup failure (missing binary, unset key, timeout) means
76
+ * "no preference detected" and animation stays enabled.
77
+ */
78
+ export function prefersReducedMotion(platform = process.platform) {
79
+ try {
80
+ if (platform === 'darwin') {
81
+ // The key only exists once the user has toggled Reduce Motion; when it
82
+ // is unset `defaults` exits non-zero and lands in the catch below.
83
+ const out = execFileSync('defaults', ['read', 'com.apple.universalaccess', 'reduceMotion'], REDUCED_MOTION_EXEC_OPTIONS);
84
+ return out.trim() === '1';
85
+ }
86
+ if (platform === 'linux') {
87
+ const out = execFileSync('gsettings', ['get', 'org.gnome.desktop.interface', 'enable-animations'], REDUCED_MOTION_EXEC_OPTIONS);
88
+ return out.trim() === 'false';
89
+ }
90
+ }
91
+ catch {
92
+ // Detection is best-effort only.
93
+ }
94
+ return false;
95
+ }
49
96
  /**
50
97
  * Checks if the terminal supports animation
51
98
  */
@@ -56,54 +103,60 @@ function canAnimate() {
56
103
  // Respect NO_COLOR
57
104
  if (process.env.NO_COLOR)
58
105
  return false;
106
+ // Manual override for users who need reduced motion (#722). Presence is
107
+ // what counts: even an empty value disables the animation.
108
+ if (process.env.OPENSPEC_NO_ANIMATION !== undefined)
109
+ return false;
59
110
  // Check terminal width
60
111
  const columns = process.stdout.columns || 80;
61
112
  if (columns < MIN_WIDTH)
62
113
  return false;
114
+ // Last so only interactive terminals pay for the OS lookup
115
+ if (prefersReducedMotion())
116
+ return false;
63
117
  return true;
64
118
  }
65
119
  /**
66
120
  * Wait for Enter key press
67
121
  */
68
- function waitForEnter() {
69
- return new Promise((resolve) => {
70
- const { stdin } = process;
71
- // Handle non-TTY gracefully
72
- if (!stdin.isTTY) {
73
- resolve();
74
- return;
75
- }
76
- const wasRaw = stdin.isRaw;
77
- stdin.setRawMode(true);
78
- stdin.resume();
79
- const onData = (data) => {
80
- const char = data.toString();
81
- // Enter key or Ctrl+C
82
- if (char === '\r' || char === '\n' || char === '\u0003') {
83
- stdin.removeListener('data', onData);
84
- stdin.setRawMode(wasRaw);
85
- stdin.pause();
86
- // Handle Ctrl+C
87
- if (char === '\u0003') {
88
- process.stdout.write('\n');
89
- process.exit(0);
90
- }
91
- resolve();
122
+ async function waitForEnter() {
123
+ if (!process.stdin.isTTY) {
124
+ return;
125
+ }
126
+ // Keep all interactive input on Inquirer's keypress lifecycle. Mixing a raw
127
+ // `data` listener between Inquirer prompts breaks arrow/space keys on Windows.
128
+ const { createPrompt, isEnterKey, useKeypress } = await import('@inquirer/core');
129
+ const prompt = createPrompt((_config, done) => {
130
+ useKeypress((key) => {
131
+ if (key.ctrl && key.name === 'c') {
132
+ process.stdout.write('\n');
133
+ process.exit(0);
134
+ }
135
+ if (isEnterKey(key)) {
136
+ done(undefined);
92
137
  }
93
- };
94
- stdin.on('data', onData);
138
+ });
139
+ return '';
95
140
  });
141
+ await prompt({});
96
142
  }
97
143
  /**
98
144
  * Shows the animated welcome screen.
99
145
  * Returns when user presses Enter.
100
146
  */
101
- export async function showWelcomeScreen() {
102
- const textLines = getWelcomeText();
103
- if (!canAnimate()) {
104
- // Fallback: show static welcome
147
+ export async function showWelcomeScreen(workflows, options = {}) {
148
+ const textLines = getWelcomeText(workflows);
149
+ if (options.animate === false || !canAnimate()) {
150
+ // Fallback: show static welcome. The "Press Enter" line is only honest
151
+ // when we actually wait; in a TTY, returning immediately would let the
152
+ // Enter it asks for fall through into the tool picker and submit the
153
+ // pre-selected tools sight-unseen. Without a TTY, drop the line instead.
154
+ const staticLines = process.stdin.isTTY
155
+ ? textLines
156
+ : textLines.filter((line) => !line.includes('Press Enter'));
105
157
  const frame = WELCOME_ANIMATION.frames[3]; // Peak frame
106
- process.stdout.write('\n' + renderFrame(frame, textLines) + '\n\n');
158
+ process.stdout.write('\n' + renderFrame(frame, staticLines) + '\n\n');
159
+ await waitForEnter();
107
160
  return;
108
161
  }
109
162
  let frameIndex = 0;
@@ -1,4 +1,5 @@
1
1
  import { type ChangeMetadata } from '../core/change-metadata/index.js';
2
+ import { type ProjectConfig } from '../core/project-config.js';
2
3
  export declare const METADATA_FILENAME = ".openspec.yaml";
3
4
  /**
4
5
  * Error thrown when change metadata validation fails.
@@ -37,6 +38,8 @@ export declare function writeChangeMetadata(changeDir: string, metadata: ChangeM
37
38
  export declare function readChangeMetadata(changeDir: string, projectRoot?: string): ChangeMetadata | null;
38
39
  export interface ResolveSchemaForChangeOptions {
39
40
  metadata?: ChangeMetadata | null;
41
+ /** Pre-read project config; suppresses the fallback config read when provided. */
42
+ projectConfig?: ProjectConfig | null;
40
43
  }
41
44
  /**
42
45
  * Resolves the schema for a change, with explicit override taking precedence.
@@ -52,4 +55,31 @@ export interface ResolveSchemaForChangeOptions {
52
55
  * @returns The resolved schema name
53
56
  */
54
57
  export declare function resolveSchemaForChange(changeDir: string, explicitSchema?: string, projectRootOverride?: string, options?: ResolveSchemaForChangeOptions): string;
58
+ export interface SkipSpecsMarker {
59
+ /**
60
+ * True when the metadata parses under ChangeMetadataSchema, sets
61
+ * skip_specs: true, and names a schema that loads.
62
+ */
63
+ declared: boolean;
64
+ /**
65
+ * Set when the marker cannot be honored: skip_specs appears in a file that
66
+ * fails the metadata contract, or the metadata file exists but cannot be
67
+ * read at all (so whether the marker is set cannot even be determined).
68
+ */
69
+ invalidReason?: string;
70
+ }
71
+ /**
72
+ * Non-throwing read of the skip_specs marker. The marker only counts when the
73
+ * metadata would load for status/instructions: the file parses under
74
+ * ChangeMetadataSchema, its schema name passes readChangeMetadata's
75
+ * listSchemas membership check, AND the schema itself loads via resolveSchema
76
+ * (a schema.yaml that exists but does not parse fails status just the same).
77
+ * Validate and archive must never honor metadata the rest of the CLI rejects,
78
+ * in either direction. The project root for schema resolution is derived from
79
+ * changeDir exactly like resolveSchemaForChange (changeDir is
80
+ * <root>/openspec/changes/<name> for every root type, including store roots).
81
+ * Missing metadata means "not declared"; a marker that cannot be honored
82
+ * yields invalidReason so callers can say why.
83
+ */
84
+ export declare function readSkipSpecsMarker(changeDir: string): SkipSpecsMarker;
55
85
  //# sourceMappingURL=change-metadata.d.ts.map
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as yaml from 'yaml';
4
4
  import { ChangeMetadataSchema } from '../core/change-metadata/index.js';
5
- import { listSchemas } from '../core/artifact-graph/resolver.js';
5
+ import { listSchemas, resolveSchema } from '../core/artifact-graph/resolver.js';
6
6
  import { readProjectConfig } from '../core/project-config.js';
7
7
  export const METADATA_FILENAME = '.openspec.yaml';
8
8
  /**
@@ -126,16 +126,110 @@ export function resolveSchemaForChange(changeDir, explicitSchema, projectRootOve
126
126
  return metadata.schema;
127
127
  }
128
128
  // 3. Try reading from project config when metadata is absent.
129
- try {
130
- const config = readProjectConfig(projectRoot);
131
- if (config?.schema) {
132
- return config.schema;
129
+ if (options.projectConfig !== undefined) {
130
+ if (options.projectConfig?.schema) {
131
+ return options.projectConfig.schema;
133
132
  }
134
133
  }
135
- catch {
136
- // If config read fails, fall back to default
134
+ else {
135
+ try {
136
+ const config = readProjectConfig(projectRoot);
137
+ if (config?.schema) {
138
+ return config.schema;
139
+ }
140
+ }
141
+ catch {
142
+ // If config read fails, fall back to default
143
+ }
137
144
  }
138
145
  // 4. Default
139
146
  return 'spec-driven';
140
147
  }
148
+ /**
149
+ * Non-throwing read of the skip_specs marker. The marker only counts when the
150
+ * metadata would load for status/instructions: the file parses under
151
+ * ChangeMetadataSchema, its schema name passes readChangeMetadata's
152
+ * listSchemas membership check, AND the schema itself loads via resolveSchema
153
+ * (a schema.yaml that exists but does not parse fails status just the same).
154
+ * Validate and archive must never honor metadata the rest of the CLI rejects,
155
+ * in either direction. The project root for schema resolution is derived from
156
+ * changeDir exactly like resolveSchemaForChange (changeDir is
157
+ * <root>/openspec/changes/<name> for every root type, including store roots).
158
+ * Missing metadata means "not declared"; a marker that cannot be honored
159
+ * yields invalidReason so callers can say why.
160
+ */
161
+ export function readSkipSpecsMarker(changeDir) {
162
+ let raw;
163
+ try {
164
+ raw = fs.readFileSync(path.join(changeDir, METADATA_FILENAME), 'utf-8');
165
+ }
166
+ catch (err) {
167
+ if (err?.code === 'ENOENT') {
168
+ return { declared: false };
169
+ }
170
+ // The file exists but cannot be read (EACCES, EISDIR, ...). Status and
171
+ // instructions reject the change outright here, and whether a marker is
172
+ // set cannot be determined - fail closed rather than let archive treat
173
+ // the change as unmarked while every metadata-reading surface errors.
174
+ const message = err instanceof Error ? err.message : String(err);
175
+ return {
176
+ declared: false,
177
+ invalidReason: `the metadata file cannot be read (${message})`,
178
+ };
179
+ }
180
+ let parsed;
181
+ try {
182
+ parsed = yaml.parse(raw);
183
+ }
184
+ catch {
185
+ // Anchored so a comment like "# maybe add skip_specs later" does not
186
+ // claim the marker was set.
187
+ return /^\s*(['"]?)skip_specs\1\s*:/m.test(raw)
188
+ ? { declared: false, invalidReason: 'the file is not valid YAML' }
189
+ : { declared: false };
190
+ }
191
+ const result = ChangeMetadataSchema.safeParse(parsed);
192
+ if (result.success) {
193
+ if (result.data.skip_specs !== true) {
194
+ return { declared: false };
195
+ }
196
+ // Schema loading is checked only when the marker is set: a broken schema
197
+ // on an ordinary change is status's problem to report, but honoring a
198
+ // marker that status rejects would let validate/archive pass what the
199
+ // rest of the CLI refuses to load. The membership check mirrors
200
+ // readChangeMetadata (which rejects names like 'spec-driven.yaml' that
201
+ // resolveSchema alone would normalize and accept); resolveSchema then
202
+ // proves the schema actually parses. Any failure fails closed.
203
+ try {
204
+ const projectRoot = path.resolve(changeDir, '../../..');
205
+ if (!listSchemas(projectRoot).includes(result.data.schema)) {
206
+ return {
207
+ declared: false,
208
+ invalidReason: `schema: unknown schema '${result.data.schema}'`,
209
+ };
210
+ }
211
+ resolveSchema(result.data.schema, projectRoot);
212
+ }
213
+ catch (err) {
214
+ const message = err instanceof Error ? err.message : String(err);
215
+ return { declared: false, invalidReason: message };
216
+ }
217
+ return { declared: true };
218
+ }
219
+ // Key presence, not value: skip_specs: "yes" must surface as unhonorable,
220
+ // not vanish while the zero-delta guidance tells the user to set the very
221
+ // marker they set. An explicit skip_specs: false is the opposite of setting
222
+ // the marker, so it must not drag unrelated metadata problems into
223
+ // validate - the change simply is not marked.
224
+ const markerMentioned = typeof parsed === 'object' &&
225
+ parsed !== null &&
226
+ 'skip_specs' in parsed &&
227
+ parsed.skip_specs !== false;
228
+ if (markerMentioned) {
229
+ const first = result.error.issues[0];
230
+ const where = first.path.length > 0 ? `${first.path.join('.')}: ` : '';
231
+ return { declared: false, invalidReason: `${where}${first.message}` };
232
+ }
233
+ return { declared: false };
234
+ }
141
235
  //# sourceMappingURL=change-metadata.js.map
@@ -31,17 +31,23 @@ export interface ValidationResult {
31
31
  /**
32
32
  * Validates that a change name follows kebab-case conventions.
33
33
  *
34
- * Valid names:
35
- * - Start with a lowercase letter
34
+ * Uses OpenSpec's shared kebab-id grammar (the same one store ids and change
35
+ * metadata ids use), so a change name may:
36
+ * - Start with a lowercase letter or a digit
36
37
  * - Contain only lowercase letters, numbers, and hyphens
37
- * - Do not start or end with a hyphen
38
- * - Do not contain consecutive hyphens
38
+ * - Not start or end with a hyphen
39
+ * - Not contain consecutive hyphens
40
+ *
41
+ * A leading digit is allowed so ordering conventions like `100-add-feature` or
42
+ * `00001-add-auth` work; archive already treats such prefixes as a supported
43
+ * convention (see ARCHIVE_DATE_PREFIX_PATTERN).
39
44
  *
40
45
  * @param name - The change name to validate
41
46
  * @returns Validation result with `valid: true` or `valid: false` with an error message
42
47
  *
43
48
  * @example
44
49
  * validateChangeName('add-auth') // { valid: true }
50
+ * validateChangeName('100-add-feature') // { valid: true }
45
51
  * validateChangeName('Add-Auth') // { valid: false, error: '...' }
46
52
  */
47
53
  export declare function validateChangeName(name: string): ValidationResult;