@fission-ai/openspec 1.13.0 → 1.13.2

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 (113) hide show
  1. package/README.md +16 -4
  2. package/dist/commands/change.js +12 -1
  3. package/dist/commands/config.d.ts +9 -0
  4. package/dist/commands/config.js +161 -22
  5. package/dist/commands/feedback.js +6 -4
  6. package/dist/commands/schema.js +30 -9
  7. package/dist/commands/store.js +5 -2
  8. package/dist/commands/validate.d.ts +7 -0
  9. package/dist/commands/validate.js +63 -1
  10. package/dist/commands/workflow/instructions.js +69 -22
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.d.ts +5 -0
  13. package/dist/commands/workflow/shared.js +8 -0
  14. package/dist/commands/workflow/status.d.ts +5 -1
  15. package/dist/commands/workflow/status.js +42 -5
  16. package/dist/core/archive.js +260 -77
  17. package/dist/core/artifact-graph/outputs.d.ts +1 -1
  18. package/dist/core/artifact-graph/outputs.js +38 -8
  19. package/dist/core/artifact-graph/schema.d.ts +14 -0
  20. package/dist/core/artifact-graph/schema.js +52 -0
  21. package/dist/core/artifact-graph/types.js +12 -1
  22. package/dist/core/change-status-policy.d.ts +14 -0
  23. package/dist/core/change-status-policy.js +14 -6
  24. package/dist/core/command-generation/adapters/continue.js +4 -0
  25. package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
  26. package/dist/core/command-generation/adapters/kilocode.js +2 -2
  27. package/dist/core/command-generation/generator.js +2 -0
  28. package/dist/core/completion-tip.js +7 -7
  29. package/dist/core/completions/installers/bash-installer.js +15 -7
  30. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  31. package/dist/core/completions/installers/shell-quote.js +13 -0
  32. package/dist/core/completions/installers/zsh-installer.js +3 -2
  33. package/dist/core/config-schema.d.ts +1 -1
  34. package/dist/core/config.d.ts +16 -0
  35. package/dist/core/config.js +27 -1
  36. package/dist/core/global-config.d.ts +30 -2
  37. package/dist/core/global-config.js +57 -3
  38. package/dist/core/init.js +22 -6
  39. package/dist/core/legacy-cleanup.d.ts +5 -1
  40. package/dist/core/legacy-cleanup.js +207 -14
  41. package/dist/core/list.js +55 -11
  42. package/dist/core/migration.js +6 -1
  43. package/dist/core/parsers/change-parser.d.ts +12 -1
  44. package/dist/core/parsers/change-parser.js +85 -87
  45. package/dist/core/parsers/markdown-parser.js +4 -3
  46. package/dist/core/parsers/requirement-blocks.d.ts +78 -6
  47. package/dist/core/parsers/requirement-blocks.js +155 -38
  48. package/dist/core/parsers/requirement-text.d.ts +13 -2
  49. package/dist/core/parsers/requirement-text.js +34 -7
  50. package/dist/core/parsers/spec-structure.js +4 -1
  51. package/dist/core/project-config.d.ts +9 -1
  52. package/dist/core/project-config.js +11 -2
  53. package/dist/core/references.d.ts +20 -0
  54. package/dist/core/references.js +56 -0
  55. package/dist/core/shared/skill-content-equivalence.js +5 -1
  56. package/dist/core/shared/skill-generation.js +36 -8
  57. package/dist/core/shared/tool-detection.js +9 -3
  58. package/dist/core/specs-apply.d.ts +20 -0
  59. package/dist/core/specs-apply.js +101 -17
  60. package/dist/core/store/git.d.ts +33 -0
  61. package/dist/core/store/git.js +60 -6
  62. package/dist/core/store/operations.d.ts +1 -1
  63. package/dist/core/store/operations.js +32 -10
  64. package/dist/core/store/registry.d.ts +2 -1
  65. package/dist/core/store/registry.js +5 -1
  66. package/dist/core/templates/optional-workflow.d.ts +69 -0
  67. package/dist/core/templates/optional-workflow.js +140 -0
  68. package/dist/core/templates/workflows/apply-change.js +18 -3
  69. package/dist/core/templates/workflows/archive-change.js +70 -21
  70. package/dist/core/templates/workflows/bulk-archive-change.js +85 -21
  71. package/dist/core/templates/workflows/continue-change.js +14 -4
  72. package/dist/core/templates/workflows/explore.js +34 -16
  73. package/dist/core/templates/workflows/ff-change.js +21 -5
  74. package/dist/core/templates/workflows/new-change.js +15 -3
  75. package/dist/core/templates/workflows/onboard.js +76 -41
  76. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  77. package/dist/core/templates/workflows/project-root.js +38 -0
  78. package/dist/core/templates/workflows/propose.js +22 -5
  79. package/dist/core/templates/workflows/sync-specs.js +40 -1
  80. package/dist/core/templates/workflows/update-change.js +66 -27
  81. package/dist/core/templates/workflows/verify-change.js +121 -49
  82. package/dist/core/update.d.ts +12 -0
  83. package/dist/core/update.js +78 -10
  84. package/dist/core/validation/purpose-placeholder.js +24 -9
  85. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  86. package/dist/core/validation/task-checkboxes.js +181 -0
  87. package/dist/core/validation/validator.d.ts +53 -1
  88. package/dist/core/validation/validator.js +160 -45
  89. package/dist/core/version-check.d.ts +14 -7
  90. package/dist/core/version-check.js +63 -17
  91. package/dist/core/worksets.js +6 -4
  92. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  93. package/dist/prompts/searchable-multi-select.js +35 -6
  94. package/dist/telemetry/config.js +14 -2
  95. package/dist/telemetry/index.d.ts +4 -3
  96. package/dist/telemetry/index.js +22 -10
  97. package/dist/telemetry/opt-out.d.ts +17 -0
  98. package/dist/telemetry/opt-out.js +27 -0
  99. package/dist/utils/file-system.js +20 -3
  100. package/dist/utils/line-endings.d.ts +34 -0
  101. package/dist/utils/line-endings.js +46 -0
  102. package/dist/utils/nested-change.d.ts +35 -0
  103. package/dist/utils/nested-change.js +186 -0
  104. package/dist/utils/spec-discovery.d.ts +20 -0
  105. package/dist/utils/spec-discovery.js +59 -0
  106. package/dist/utils/task-progress.d.ts +1 -1
  107. package/dist/utils/task-progress.js +38 -6
  108. package/package.json +5 -5
  109. package/schemas/spec-driven/schema.yaml +18 -3
  110. package/schemas/spec-driven/templates/design.md +2 -0
  111. package/schemas/spec-driven/templates/proposal.md +2 -0
  112. package/schemas/spec-driven/templates/spec.md +2 -0
  113. package/schemas/spec-driven/templates/tasks.md +2 -0
package/README.md CHANGED
@@ -122,7 +122,7 @@ Solo, OpenSpec keeps you and your AI honest on a single repo. On a team, the har
122
122
 
123
123
  ## Quick Start
124
124
 
125
- **Requires Node.js 20.19.0 or higher.**
125
+ **Requires Node.js 20.19.0 or higher.** Homebrew installs it as a dependency.
126
126
 
127
127
  Install OpenSpec globally:
128
128
 
@@ -130,6 +130,12 @@ Install OpenSpec globally:
130
130
  npm install -g @fission-ai/openspec@latest
131
131
  ```
132
132
 
133
+ Or install the official [Homebrew formula](https://formulae.brew.sh/formula/openspec) on macOS or Linux:
134
+
135
+ ```bash
136
+ brew install openspec
137
+ ```
138
+
133
139
  Then navigate to your project directory and initialize:
134
140
 
135
141
  ```bash
@@ -137,11 +143,11 @@ cd your-project
137
143
  openspec init
138
144
  ```
139
145
 
140
- > **Want your AI to do it?** Paste the [setup prompt](docs/installation.md#install-with-your-ai-assistant) into your coding assistant — it installs the CLI, runs `openspec init`, and verifies the result.
146
+ > **Want your AI to do it?** Paste the [setup prompt](docs-lab/start/installation.md#install-with-your-ai-assistant) into your coding assistant — it installs the CLI, runs `openspec init`, and verifies the result.
141
147
 
142
148
  Now talk to your AI:
143
149
 
144
- - **Not sure what to build yet?** Start with `/opsx:explore`, a no-stakes thinking partner that reads your code, weighs options, and shapes a plan before anything is written. ([Explore guide](docs/explore.md))
150
+ - **Not sure what to build yet?** Start with `/opsx:explore`, a no-stakes thinking partner that reads your code, weighs options, and shapes a plan before any code gets written. ([Explore guide](docs/explore.md))
145
151
  - **Already know what you want?** Go straight to `/opsx:propose <what-you-want-to-build>`.
146
152
 
147
153
  Both are in the default profile. If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:bulk-archive`, `/opsx:onboard`), select it with `openspec config profile` and apply with `openspec update`.
@@ -151,7 +157,7 @@ Both are in the default profile. If you want the expanded workflow (`/opsx:new`,
151
157
  > [!NOTE]
152
158
  > Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 30+ tools and growing.
153
159
  >
154
- > Also works with pnpm, yarn, bun, and nix. [See installation options](docs/installation.md).
160
+ > Also works with Homebrew, pnpm, yarn, bun, and Nix. [See installation options](docs-lab/start/installation.md).
155
161
 
156
162
  ## Docs
157
163
 
@@ -208,6 +214,12 @@ AI coding assistants are powerful but unpredictable when requirements live only
208
214
  npm install -g @fission-ai/openspec@latest
209
215
  ```
210
216
 
217
+ If you installed OpenSpec with Homebrew:
218
+
219
+ ```bash
220
+ brew upgrade openspec
221
+ ```
222
+
211
223
  **Refresh agent instructions**
212
224
 
213
225
  Run this inside each project to regenerate AI guidance and ensure the latest slash commands are active:
@@ -7,6 +7,7 @@ import { VALIDATION_MESSAGES } from '../core/validation/constants.js';
7
7
  import { ChangeParser } from '../core/parsers/change-parser.js';
8
8
  import { isInteractive } from '../utils/interactive.js';
9
9
  import { getActiveChangeIds } from '../utils/item-discovery.js';
10
+ import { describeNestedChange, findNestedChangesIn, } from '../utils/nested-change.js';
10
11
  import { getTaskProgressForChange } from '../utils/task-progress.js';
11
12
  import { FileSystemUtils } from '../utils/file-system.js';
12
13
  import { discoverSpecFiles } from '../utils/spec-discovery.js';
@@ -102,6 +103,13 @@ export class ChangeCommand {
102
103
  .then((stats) => stats.isDirectory())
103
104
  .catch(() => false);
104
105
  if (isChangeDirectory) {
106
+ // A folder holding nested change directories has no proposal of its
107
+ // own and never will; pointing at `status --change` would send the
108
+ // user down a second dead end (#1846).
109
+ const nested = await findNestedChangesIn(changesPath, changeName);
110
+ if (nested) {
111
+ throw new Error(describeNestedChange(nested));
112
+ }
105
113
  throw new Error(`Change "${changeName}" has no proposal.md yet. ` +
106
114
  `Run "openspec status --change ${changeName}" to see which artifact comes next.`);
107
115
  }
@@ -496,7 +504,10 @@ export class ChangeCommand {
496
504
  }
497
505
  extractTitle(content, changeName) {
498
506
  const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/im);
499
- return match ? match[1].trim() : changeName;
507
+ const title = match?.[1].trim();
508
+ // The packaged template opens every proposal with a bare `# Proposal`,
509
+ // which names the document rather than the change.
510
+ return title && title.toLowerCase() !== 'proposal' ? title : changeName;
500
511
  }
501
512
  printNextSteps(issues = []) {
502
513
  const bullets = [];
@@ -1,6 +1,15 @@
1
1
  import { Command } from 'commander';
2
2
  import { GlobalConfig } from '../core/global-config.js';
3
3
  import type { Profile, Delivery } from '../core/global-config.js';
4
+ /**
5
+ * Splits an EDITOR or VISUAL value into a program and its arguments without
6
+ * running a shell, so `;`, `|`, `$VAR`, `~` and backticks are plain characters.
7
+ * Double quotes group words. On POSIX, single quotes group words too and a
8
+ * backslash escapes the next character (inside double quotes only `"` and `\`).
9
+ * On Windows a backslash is a path separator and a single quote is a plain
10
+ * character. Returns null when a quote is left open.
11
+ */
12
+ export declare function splitEditorCommand(value: string, platform?: NodeJS.Platform): string[] | null;
4
13
  interface ProfileState {
5
14
  profile: Profile;
6
15
  delivery: Delivery;
@@ -1,13 +1,148 @@
1
- import { spawn } from 'node:child_process';
2
1
  import * as fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
3
  import * as path from 'node:path';
4
- import { getGlobalConfigPath, getGlobalConfig, saveGlobalConfig, } from '../core/global-config.js';
4
+ import { getGlobalConfigPath, getGlobalConfig, isConfigRootObject, isGlobalConfigUnreadable, saveGlobalConfig, } from '../core/global-config.js';
5
5
  import { getNestedValue, setNestedValue, deleteNestedValue, coerceValue, formatValueYaml, validateConfigKeyPath, hasUnsafeKeySegment, validateConfig, DEFAULT_CONFIG, } from '../core/config-schema.js';
6
6
  import { CORE_WORKFLOWS, ALL_WORKFLOWS, getProfileWorkflows } from '../core/profiles.js';
7
7
  import { OPENSPEC_DIR_NAME } from '../core/config.js';
8
8
  import { hasProjectConfigDrift } from '../core/profile-sync-drift.js';
9
9
  import { UpdateCommand } from '../core/update.js';
10
10
  import { asErrorMessage, isPromptCancellationError } from './shared-output.js';
11
+ // cross-spawn finds `.cmd` shims such as `code.cmd` on Windows and escapes each
12
+ // argument for cmd.exe; elsewhere it is plain spawn. Loaded lazily so other
13
+ // commands skip its module graph.
14
+ let cachedSpawn;
15
+ function loadSpawn() {
16
+ if (cachedSpawn === undefined) {
17
+ cachedSpawn = createRequire(import.meta.url)('cross-spawn');
18
+ }
19
+ return cachedSpawn;
20
+ }
21
+ /**
22
+ * Splits an EDITOR or VISUAL value into a program and its arguments without
23
+ * running a shell, so `;`, `|`, `$VAR`, `~` and backticks are plain characters.
24
+ * Double quotes group words. On POSIX, single quotes group words too and a
25
+ * backslash escapes the next character (inside double quotes only `"` and `\`).
26
+ * On Windows a backslash is a path separator and a single quote is a plain
27
+ * character. Returns null when a quote is left open.
28
+ */
29
+ export function splitEditorCommand(value, platform = process.platform) {
30
+ const posix = platform !== 'win32';
31
+ const words = [];
32
+ let word = '';
33
+ let inWord = false;
34
+ let quote = null;
35
+ for (let i = 0; i < value.length; i++) {
36
+ const ch = value[i];
37
+ if (quote === "'") {
38
+ if (ch === "'")
39
+ quote = null;
40
+ else
41
+ word += ch;
42
+ continue;
43
+ }
44
+ if (posix && ch === '\\' && i + 1 < value.length) {
45
+ const next = value[i + 1];
46
+ if (quote === '"' && next !== '"' && next !== '\\') {
47
+ word += ch;
48
+ }
49
+ else {
50
+ word += next;
51
+ i++;
52
+ }
53
+ inWord = true;
54
+ continue;
55
+ }
56
+ if (quote === '"') {
57
+ if (ch === '"')
58
+ quote = null;
59
+ else
60
+ word += ch;
61
+ continue;
62
+ }
63
+ if (ch === '"' || (posix && ch === "'")) {
64
+ quote = ch;
65
+ inWord = true;
66
+ continue;
67
+ }
68
+ if (/\s/.test(ch)) {
69
+ if (inWord)
70
+ words.push(word);
71
+ word = '';
72
+ inWord = false;
73
+ continue;
74
+ }
75
+ word += ch;
76
+ inWord = true;
77
+ }
78
+ if (quote)
79
+ return null;
80
+ if (inWord)
81
+ words.push(word);
82
+ return words;
83
+ }
84
+ /**
85
+ * Starts the user's editor on `filePath`, never through a shell.
86
+ *
87
+ * EDITOR and VISUAL hold a command line, not a program name: `code --wait`
88
+ * and `"/path with spaces/subl" -w` are both ordinary values, so the value is
89
+ * split into words and the file path is appended as its own argument. A value
90
+ * that is itself the absolute path of an existing file is run as-is, so an
91
+ * unquoted editor path with spaces keeps working.
92
+ */
93
+ function spawnEditor(editor, filePath) {
94
+ const words = path.isAbsolute(editor) && fs.existsSync(editor) ? [editor] : splitEditorCommand(editor);
95
+ if (words === null) {
96
+ throw new Error('the value has an unterminated quote');
97
+ }
98
+ if (words.length === 0) {
99
+ throw new Error('the value is blank');
100
+ }
101
+ const [program, ...args] = words;
102
+ return loadSpawn()(program, [...args, filePath], { stdio: 'inherit', shell: false });
103
+ }
104
+ /** Runs the editor on `filePath` and resolves once it has closed or failed to start. */
105
+ function runEditor(editor, filePath) {
106
+ return new Promise((resolve) => {
107
+ try {
108
+ const child = spawnEditor(editor, filePath);
109
+ child.once('error', (error) => resolve({ error }));
110
+ child.once('close', (code, signal) => resolve({ code, signal }));
111
+ }
112
+ catch (error) {
113
+ resolve({ error: error instanceof Error ? error : new Error(String(error)) });
114
+ }
115
+ });
116
+ }
117
+ function reportEditorFailure(editor, outcome) {
118
+ if ('error' in outcome) {
119
+ console.error(`Error: Could not start editor "${editor}": ${outcome.error.message}`);
120
+ }
121
+ else if (outcome.signal) {
122
+ console.error(`Error: Editor "${editor}" was terminated by ${outcome.signal}`);
123
+ }
124
+ else {
125
+ console.error(`Error: Editor "${editor}" exited with code ${outcome.code}`);
126
+ }
127
+ // Only a missing program earns the hint: EACCES or EPERM means it exists.
128
+ if ('error' in outcome && outcome.error.code === 'ENOENT') {
129
+ console.error('Set EDITOR or VISUAL to an installed editor command, for example: export EDITOR="code --wait"');
130
+ }
131
+ }
132
+ /**
133
+ * A config file that exists but cannot be parsed is still the user's file:
134
+ * getGlobalConfig() reads it as defaults, and saving those back would erase
135
+ * every setting in it. Reports the fix instead, and returns true when it did.
136
+ */
137
+ function refuseUnreadableConfig() {
138
+ if (!isGlobalConfigUnreadable()) {
139
+ return false;
140
+ }
141
+ console.error(`Error: ${getGlobalConfigPath()} could not be parsed, so it was left unchanged.`);
142
+ console.error('Fix it with "openspec config edit", or reset it with "openspec config reset --all".');
143
+ process.exitCode = 1;
144
+ return true;
145
+ }
11
146
  export const WORKFLOW_PROMPT_META = {
12
147
  propose: {
13
148
  name: 'Propose change',
@@ -187,7 +322,12 @@ export function registerConfigCommand(program) {
187
322
  let rawConfig = {};
188
323
  try {
189
324
  if (fs.existsSync(configPath)) {
190
- rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
325
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
326
+ // A non-object root holds no explicit settings, and reading a key
327
+ // off `null` would crash this read-only command.
328
+ if (isConfigRootObject(parsed)) {
329
+ rawConfig = parsed;
330
+ }
191
331
  }
192
332
  }
193
333
  catch {
@@ -250,6 +390,9 @@ export function registerConfigCommand(program) {
250
390
  process.exitCode = 1;
251
391
  return;
252
392
  }
393
+ if (refuseUnreadableConfig()) {
394
+ return;
395
+ }
253
396
  const config = getGlobalConfig();
254
397
  const coercedValue = coerceValue(value, options.string || false);
255
398
  // Create a copy to validate before saving
@@ -273,6 +416,9 @@ export function registerConfigCommand(program) {
273
416
  .command('unset <key>')
274
417
  .description('Remove a key (revert to default)')
275
418
  .action((key) => {
419
+ if (refuseUnreadableConfig()) {
420
+ return;
421
+ }
276
422
  const config = getGlobalConfig();
277
423
  const existed = deleteNestedValue(config, key);
278
424
  if (existed) {
@@ -318,7 +464,8 @@ export function registerConfigCommand(program) {
318
464
  return;
319
465
  }
320
466
  }
321
- saveGlobalConfig({ ...DEFAULT_CONFIG });
467
+ // A reset is the one write meant to replace a file that cannot be parsed.
468
+ saveGlobalConfig({ ...DEFAULT_CONFIG }, { replaceUnreadable: true });
322
469
  console.log('Configuration reset to defaults');
323
470
  });
324
471
  // config edit
@@ -339,24 +486,13 @@ export function registerConfigCommand(program) {
339
486
  if (!fs.existsSync(configPath)) {
340
487
  saveGlobalConfig({ ...DEFAULT_CONFIG });
341
488
  }
342
- // Spawn editor and wait for it to close
343
- // Avoid shell parsing to correctly handle paths with spaces in both
344
- // the editor path and config path
345
- const child = spawn(editor, [configPath], {
346
- stdio: 'inherit',
347
- shell: false,
348
- });
349
- await new Promise((resolve, reject) => {
350
- child.on('close', (code) => {
351
- if (code === 0) {
352
- resolve();
353
- }
354
- else {
355
- reject(new Error(`Editor exited with code ${code}`));
356
- }
357
- });
358
- child.on('error', reject);
359
- });
489
+ // Wait for the editor to close; a failure is reported, never thrown.
490
+ const outcome = await runEditor(editor, configPath);
491
+ if ('error' in outcome || outcome.code !== 0) {
492
+ reportEditorFailure(editor, outcome);
493
+ process.exitCode = 1;
494
+ return;
495
+ }
360
496
  try {
361
497
  const rawConfig = fs.readFileSync(configPath, 'utf-8');
362
498
  const parsedConfig = JSON.parse(rawConfig);
@@ -385,6 +521,9 @@ export function registerConfigCommand(program) {
385
521
  .command('profile [preset]')
386
522
  .description('Configure workflow profile (interactive picker or preset shortcut)')
387
523
  .action(async (preset) => {
524
+ if (refuseUnreadableConfig()) {
525
+ return;
526
+ }
388
527
  // Preset shortcut: `openspec config profile core`
389
528
  if (preset === 'core') {
390
529
  const config = getGlobalConfig();
@@ -1,4 +1,4 @@
1
- import { execSync, execFileSync } from 'child_process';
1
+ import { execFileSync } from 'child_process';
2
2
  import { createRequire } from 'module';
3
3
  import os from 'os';
4
4
  const require = createRequire(import.meta.url);
@@ -10,8 +10,10 @@ const TITLE_PREFIX = 'Feedback: ';
10
10
  */
11
11
  function isGhInstalled() {
12
12
  try {
13
- const command = process.platform === 'win32' ? 'where gh' : 'which gh';
14
- execSync(command, { stdio: 'pipe' });
13
+ // execFileSync, not execSync: no shell is needed to look a binary up, and
14
+ // spawning one next to free-form issue text is the shape a future refactor
15
+ // most easily turns into command injection.
16
+ execFileSync(process.platform === 'win32' ? 'where' : 'which', ['gh'], { stdio: 'pipe' });
15
17
  return true;
16
18
  }
17
19
  catch {
@@ -23,7 +25,7 @@ function isGhInstalled() {
23
25
  */
24
26
  function isGhAuthenticated() {
25
27
  try {
26
- execSync('gh auth status', { stdio: 'pipe' });
28
+ execFileSync('gh', ['auth', 'status'], { stdio: 'pipe' });
27
29
  return true;
28
30
  }
29
31
  catch {
@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto';
4
4
  import ora from 'ora';
5
5
  import { stringify as stringifyYaml, parseDocument, isMap } from 'yaml';
6
6
  import { getSchemaDir, getProjectSchemasDir, getUserSchemasDir, getPackageSchemasDir, isSchemaDir, listSchemas, } from '../core/artifact-graph/resolver.js';
7
- import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js';
7
+ import { findApplyTracksWarning, parseSchema, SchemaValidationError, } from '../core/artifact-graph/schema.js';
8
8
  import { resolveConfigFilePath } from '../core/project-config.js';
9
9
  import { FileSystemUtils } from '../utils/file-system.js';
10
10
  /**
@@ -158,12 +158,18 @@ function validateSchema(schemaDir, verbose = false) {
158
158
  });
159
159
  }
160
160
  }
161
- // Dependency graph validation is already done by parseSchema
162
- // (it throws on cycles and invalid references)
161
+ // Dependency graph validation is already done by parseSchema (it throws on
162
+ // cycles, invalid references, and an unknown apply.requires id)
163
163
  if (verbose) {
164
164
  console.log(' Dependency graph validation passed (via parseSchema)');
165
165
  }
166
- return { valid: issues.length === 0, issues };
166
+ // An apply.tracks value that matches no generates value exactly still loads
167
+ // (apply reads the path as written), so it is a warning, not an error.
168
+ const tracksWarning = findApplyTracksWarning(schema);
169
+ if (tracksWarning) {
170
+ issues.push({ level: 'warning', path: 'apply.tracks', message: tracksWarning });
171
+ }
172
+ return { valid: !issues.some((issue) => issue.level === 'error'), issues };
167
173
  }
168
174
  /**
169
175
  * Validate schema name format (kebab-case).
@@ -590,6 +596,9 @@ export function registerSchemaCommand(program) {
590
596
  else {
591
597
  if (result.valid) {
592
598
  console.log(`✓ Schema '${name}' is valid`);
599
+ for (const issue of result.issues) {
600
+ console.log(` ${issue.level}: ${issue.message}`);
601
+ }
593
602
  }
594
603
  else {
595
604
  console.log(`✗ Schema '${name}' has errors:`);
@@ -1201,11 +1210,17 @@ export function registerSchemaCommand(program) {
1201
1210
  }
1202
1211
  /**
1203
1212
  * Create default template content for an artifact.
1213
+ *
1214
+ * Every template opens with a top-level heading so the artifact it produces is
1215
+ * a well-formed markdown document rather than a file whose first line is a
1216
+ * section header (markdownlint MD041, #1138).
1204
1217
  */
1205
1218
  function createDefaultTemplate(artifactId) {
1206
1219
  switch (artifactId) {
1207
1220
  case 'proposal':
1208
- return `## Why
1221
+ return `# Proposal
1222
+
1223
+ ## Why
1209
1224
 
1210
1225
  <!-- Describe the motivation for this change -->
1211
1226
 
@@ -1226,7 +1241,9 @@ function createDefaultTemplate(artifactId) {
1226
1241
  <!-- Describe the impact on existing functionality -->
1227
1242
  `;
1228
1243
  case 'specs':
1229
- return `## ADDED Requirements
1244
+ return `# Spec Delta
1245
+
1246
+ ## ADDED Requirements
1230
1247
 
1231
1248
  ### Requirement: Example requirement
1232
1249
 
@@ -1237,7 +1254,9 @@ Description of the requirement.
1237
1254
  - **THEN** some outcome
1238
1255
  `;
1239
1256
  case 'design':
1240
- return `## Context
1257
+ return `# Design
1258
+
1259
+ ## Context
1241
1260
 
1242
1261
  <!-- Background and context -->
1243
1262
 
@@ -1263,14 +1282,16 @@ Description and rationale.
1263
1282
  <!-- List risks and trade-offs -->
1264
1283
  `;
1265
1284
  case 'tasks':
1266
- return `## Implementation Tasks
1285
+ return `# Tasks
1286
+
1287
+ ## Implementation Tasks
1267
1288
 
1268
1289
  - [ ] Task 1
1269
1290
  - [ ] Task 2
1270
1291
  - [ ] Task 3
1271
1292
  `;
1272
1293
  default:
1273
- return `## ${artifactId}
1294
+ return `# ${artifactId}
1274
1295
 
1275
1296
  <!-- Add content here -->
1276
1297
  `;
@@ -143,8 +143,11 @@ async function resolveSetupInput(id, options) {
143
143
  ...(options.remote !== undefined ? { remote: options.remote } : {}),
144
144
  };
145
145
  }
146
- async function prepareSetupInput(input, _options) {
147
- return prepareStoreSetup(input);
146
+ async function prepareSetupInput(input, options) {
147
+ return prepareStoreSetup({
148
+ ...input,
149
+ ...(options.initGit !== undefined ? { initGit: options.initGit } : {}),
150
+ });
148
151
  }
149
152
  async function confirmSetup(prepared, initGit) {
150
153
  const { confirm } = await import('@inquirer/prompts');
@@ -81,6 +81,13 @@ export declare class ValidateCommand {
81
81
  private runInteractiveSelector;
82
82
  private printNonInteractiveHint;
83
83
  private validateDirectItem;
84
+ /**
85
+ * A namespace folder wrapping nested change directories has no deltas of its
86
+ * own and never will. The usual "add a delta spec" error points the author at
87
+ * a directory that is not the change, so the nesting is reported instead
88
+ * (#1846). Returns undefined for every ordinary change.
89
+ */
90
+ private nestedChangeReport;
84
91
  private validateByType;
85
92
  private printReport;
86
93
  private printNextSteps;
@@ -1,5 +1,6 @@
1
1
  import ora from 'ora';
2
2
  import path from 'path';
3
+ import { describeNestedChange, findNestedChangesIn, NESTED_CHANGE_ISSUE_MARKER, } from '../utils/nested-change.js';
3
4
  import { Validator } from '../core/validation/validator.js';
4
5
  import { VALIDATION_MESSAGES } from '../core/validation/constants.js';
5
6
  import { resolveRootForCommand, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from '../core/root-selection.js';
@@ -10,6 +11,7 @@ import { nearestMatches } from '../utils/match.js';
10
11
  import { promises as fs } from 'fs';
11
12
  import { getTaskProgressDetailForChange } from '../utils/task-progress.js';
12
13
  import { FileSystemUtils } from '../utils/file-system.js';
14
+ import { folderStyleNameProblem } from '../core/id.js';
13
15
  /** Findings are a distinct report, not a partial full-v1 items collection. */
14
16
  export function projectValidationFindings(full, scope) {
15
17
  const itemFindings = full.items.filter(item => item.issues.length > 0);
@@ -204,11 +206,54 @@ export class ValidateCommand {
204
206
  }
205
207
  await this.validateByType(root, type, itemName, opts);
206
208
  }
209
+ /**
210
+ * A namespace folder wrapping nested change directories has no deltas of its
211
+ * own and never will. The usual "add a delta spec" error points the author at
212
+ * a directory that is not the change, so the nesting is reported instead
213
+ * (#1846). Returns undefined for every ordinary change.
214
+ */
215
+ async nestedChangeReport(root, id) {
216
+ const nested = await findNestedChangesIn(root.changesDir, id);
217
+ if (!nested)
218
+ return undefined;
219
+ return {
220
+ valid: false,
221
+ issues: [{ level: 'ERROR', path: 'file', message: describeNestedChange(nested) }],
222
+ };
223
+ }
207
224
  async validateByType(root, type, id, opts) {
225
+ // `--type` skips the membership check above, so the name still has to be
226
+ // guarded before it is joined onto a directory. `show` already rejects a
227
+ // traversing id.
228
+ //
229
+ // Spec ids are nested (`specs/<area>/<capability>/spec.md`, #1353), so the
230
+ // guard runs per segment - rejecting the whole id for containing a `/`
231
+ // would break every nested capability, including the hint that
232
+ // `validate --specs` prints. Change names are flat, so they keep the
233
+ // whole-value check.
234
+ const nameProblem = type === 'change'
235
+ ? folderStyleNameProblem(id, 'Change name')
236
+ : (id.split('/').map((segment) => folderStyleNameProblem(segment, 'Spec id')).find(Boolean) ?? null);
237
+ if (nameProblem) {
238
+ if (opts.json) {
239
+ console.log(JSON.stringify({ status: [{ severity: 'error', code: 'invalid_item', message: nameProblem }] }, null, 2));
240
+ }
241
+ else {
242
+ console.error(nameProblem);
243
+ }
244
+ process.exitCode = 1;
245
+ return;
246
+ }
208
247
  const validator = new Validator(opts.strict);
209
248
  if (type === 'change') {
210
249
  const changeDir = path.join(root.changesDir, id);
211
250
  const start = Date.now();
251
+ const nestedReport = await this.nestedChangeReport(root, id);
252
+ if (nestedReport) {
253
+ this.printReport('change', id, nestedReport, Date.now() - start, opts.json, root);
254
+ process.exitCode = 1;
255
+ return;
256
+ }
212
257
  const report = await validator.validateChangeDeltaSpecs(changeDir, {
213
258
  mainSpecsDir: root.specsDir,
214
259
  projectRoot: root.path,
@@ -254,7 +299,14 @@ export class ValidateCommand {
254
299
  // skip_specs, which must not trigger this.
255
300
  const conflictIssue = issues.some(i => i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT));
256
301
  const invalidMarkerIssue = issues.some(i => i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA));
257
- if (type === 'change' && conflictIssue) {
302
+ // A namespace folder has no deltas to author, so the delta-authoring
303
+ // bullets below would point at a directory that is not the change (#1846).
304
+ const nestedIssue = issues.some(i => i.message.includes(NESTED_CHANGE_ISSUE_MARKER));
305
+ if (type === 'change' && nestedIssue) {
306
+ bullets.push('- Move each nested change directly under openspec/changes/, folding the namespace into its name');
307
+ bullets.push('- Only specs may be nested by domain; change directories are always flat');
308
+ }
309
+ else if (type === 'change' && conflictIssue) {
258
310
  bullets.push('- This change declares skip_specs (no spec deltas): delete the files under specs/, or remove skip_specs from .openspec.yaml if requirements do change');
259
311
  bullets.push('- skip_specs is only honored when .openspec.yaml is valid change metadata (schema: <name> naming a known schema is required)');
260
312
  }
@@ -318,6 +370,16 @@ export class ValidateCommand {
318
370
  queue.push(async () => {
319
371
  const start = Date.now();
320
372
  const changeDir = path.join(root.changesDir, id);
373
+ const nestedReport = await this.nestedChangeReport(root, id);
374
+ if (nestedReport) {
375
+ return {
376
+ id,
377
+ type: 'change',
378
+ valid: false,
379
+ issues: nestedReport.issues,
380
+ durationMs: Date.now() - start,
381
+ };
382
+ }
321
383
  const report = await validator.validateChangeDeltaSpecs(changeDir, {
322
384
  mainSpecsDir: root.specsDir,
323
385
  projectRoot: root.path,