@fission-ai/openspec 1.8.0 → 1.9.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 (48) hide show
  1. package/dist/cli/index.d.ts +15 -0
  2. package/dist/cli/index.js +33 -4
  3. package/dist/commands/change.js +1 -0
  4. package/dist/commands/config.d.ts +5 -0
  5. package/dist/commands/config.js +5 -1
  6. package/dist/commands/schema.js +193 -28
  7. package/dist/commands/validate.d.ts +23 -0
  8. package/dist/commands/validate.js +148 -4
  9. package/dist/commands/workflow/schemas.d.ts +2 -0
  10. package/dist/commands/workflow/schemas.js +9 -2
  11. package/dist/core/archive.js +10 -3
  12. package/dist/core/artifact-graph/resolver.d.ts +0 -12
  13. package/dist/core/artifact-graph/resolver.js +14 -0
  14. package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
  15. package/dist/core/command-generation/adapters/command-code.js +34 -0
  16. package/dist/core/command-generation/adapters/index.d.ts +1 -0
  17. package/dist/core/command-generation/adapters/index.js +1 -0
  18. package/dist/core/command-generation/registry.js +2 -0
  19. package/dist/core/completions/command-registry.js +5 -0
  20. package/dist/core/config.d.ts +1 -0
  21. package/dist/core/config.js +16 -15
  22. package/dist/core/init.js +19 -7
  23. package/dist/core/legacy-cleanup.d.ts +10 -0
  24. package/dist/core/legacy-cleanup.js +48 -26
  25. package/dist/core/parsers/requirement-blocks.js +41 -9
  26. package/dist/core/parsers/requirement-text.d.ts +8 -0
  27. package/dist/core/parsers/requirement-text.js +4 -3
  28. package/dist/core/root-selection.d.ts +1 -1
  29. package/dist/core/shared-skill-target.d.ts +15 -0
  30. package/dist/core/shared-skill-target.js +30 -0
  31. package/dist/core/specs-apply.js +11 -4
  32. package/dist/core/templates/workflows/apply-change.js +3 -0
  33. package/dist/core/templates/workflows/propose.js +2 -2
  34. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  35. package/dist/core/templates/workflows/store-selection.js +1 -1
  36. package/dist/core/templates/workflows/update-change.js +4 -4
  37. package/dist/core/update.js +34 -7
  38. package/dist/core/validation/task-numbering.d.ts +16 -0
  39. package/dist/core/validation/task-numbering.js +57 -0
  40. package/dist/core/validation/validator.d.ts +5 -2
  41. package/dist/core/validation/validator.js +57 -3
  42. package/dist/telemetry/index.d.ts +3 -1
  43. package/dist/telemetry/index.js +7 -1
  44. package/dist/utils/interactive.d.ts +30 -1
  45. package/dist/utils/interactive.js +105 -2
  46. package/dist/utils/task-progress.d.ts +34 -1
  47. package/dist/utils/task-progress.js +52 -28
  48. package/package.json +1 -1
@@ -10,7 +10,7 @@ import { emitStoreRootBanner, isRootSelectionError, resolveOpenSpecRoot, toRootO
10
10
  import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, retireSpec, finalizeRetiredSpec, } from './specs-apply.js';
11
11
  import { discoverSpecFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
12
12
  import { METADATA_FILENAME, readRetireCapabilitiesMarker, readSkipSpecsMarker } from '../utils/change-metadata.js';
13
- import { isNonInteractivePromptError } from '../utils/interactive.js';
13
+ import { confirmPrompt, isNonInteractivePromptError } from '../utils/interactive.js';
14
14
  import { FileSystemUtils } from '../utils/file-system.js';
15
15
  import { folderStyleNameProblem } from './id.js';
16
16
  function isMissingPathError(error) {
@@ -202,9 +202,8 @@ function rerunCommand(root, changeName, options) {
202
202
  * `User force closed the prompt` (#1479).
203
203
  */
204
204
  async function confirmOrBlock(prompt, blocked) {
205
- const { confirm } = await import('@inquirer/prompts');
206
205
  try {
207
- return await confirm(prompt);
206
+ return await confirmPrompt(prompt);
208
207
  }
209
208
  catch (error) {
210
209
  if (isNonInteractivePromptError(error)) {
@@ -1603,6 +1602,14 @@ export class ArchiveCommand {
1603
1602
  console.log('No active changes found.');
1604
1603
  return null;
1605
1604
  }
1605
+ // A picker needs a real terminal, and @inquirer's `select` writes ANSI
1606
+ // cursor escapes to stdout even when it is redirected — the same #1526
1607
+ // mechanism the confirm prompts were fixed for. When either stream is not a
1608
+ // TTY, refuse up front with the guidance the caught ExitPromptError would
1609
+ // give, rather than render an escape-spewing menu into a pipe or file.
1610
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1611
+ throw new ArchiveBlockedError('archive_change_name_required', 'A change name is required: no terminal is available to choose one from a list.', withStoreFlag(root, `openspec archive <change-name> ${rerunFlags(options).join(' ')}`));
1612
+ }
1606
1613
  // Build choices with progress inline to avoid duplicate lists
1607
1614
  let choices = changeDirs.map(name => ({ name, value: name }));
1608
1615
  try {
@@ -23,18 +23,6 @@ export declare function getUserSchemasDir(): string;
23
23
  * @returns The path to the project's schemas directory
24
24
  */
25
25
  export declare function getProjectSchemasDir(projectRoot: string): string;
26
- /**
27
- * Determines whether a directory entry represents a schema directory candidate.
28
- *
29
- * Returns true for real directories and for symlinks whose target is a
30
- * directory. `fs.Dirent.isDirectory()` reports the raw entry type, so a symlink
31
- * (even one pointing at a directory) has `isDirectory() === false`; we
32
- * dereference such entries via `fs.statSync` to admit symlinked schema dirs
33
- * while still rejecting symlinks-to-files and broken/dangling symlinks.
34
- *
35
- * @param parentDir - The directory containing the entry
36
- * @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })`
37
- */
38
26
  export declare function isSchemaDir(parentDir: string, entry: fs.Dirent): boolean;
39
27
  /**
40
28
  * Resolves a schema name to its directory path.
@@ -52,7 +52,21 @@ export function getProjectSchemasDir(projectRoot) {
52
52
  * @param parentDir - The directory containing the entry
53
53
  * @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })`
54
54
  */
55
+ /**
56
+ * Directories `schema fork` creates transiently while swapping a fork into
57
+ * place: a staging copy (`.fork-staging-<rand>`, created via mkdtemp) and a
58
+ * backup of the previous destination (`<name>.fork-backup-<pid>-<ts>`). Either
59
+ * can briefly coexist with real schemas in the schemas dir, so discovery must
60
+ * never surface them. Real schema names are kebab-case (no dots), so excluding
61
+ * these dot-bearing temp names can never hide a legitimate schema.
62
+ */
63
+ function isOwnedForkTempDir(name) {
64
+ return name.startsWith('.fork-staging-') || name.includes('.fork-backup-');
65
+ }
55
66
  export function isSchemaDir(parentDir, entry) {
67
+ if (isOwnedForkTempDir(entry.name)) {
68
+ return false;
69
+ }
56
70
  if (entry.isDirectory()) {
57
71
  return true;
58
72
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Command Code Command Adapter
3
+ *
4
+ * Command Code reads custom slash commands from `.commandcode/commands/`. The
5
+ * command name is the markdown filename without its `.md` extension, so
6
+ * `opsx-<id>.md` registers `/opsx-<id>` — the same flat naming Cursor and
7
+ * OpenCode use. See https://commandcode.ai/docs/reference/slash-commands.
8
+ */
9
+ import type { ToolCommandAdapter } from '../types.js';
10
+ /**
11
+ * Command Code adapter for command generation.
12
+ * File path: .commandcode/commands/opsx-<id>.md
13
+ * Format: plain Markdown with $ARGUMENTS injected after the input contract
14
+ *
15
+ * Command Code executes the full trimmed file body and substitutes invocation
16
+ * arguments only where the body includes one of its argument placeholders.
17
+ */
18
+ export declare const commandCodeAdapter: ToolCommandAdapter;
19
+ //# sourceMappingURL=command-code.d.ts.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Command Code Command Adapter
3
+ *
4
+ * Command Code reads custom slash commands from `.commandcode/commands/`. The
5
+ * command name is the markdown filename without its `.md` extension, so
6
+ * `opsx-<id>.md` registers `/opsx-<id>` — the same flat naming Cursor and
7
+ * OpenCode use. See https://commandcode.ai/docs/reference/slash-commands.
8
+ */
9
+ import path from 'path';
10
+ const COMMAND_CODE_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m;
11
+ function injectCommandCodeArgs(body) {
12
+ if (/^\*\*Provided arguments\*\*:\s*(?:\$(?:ARGUMENTS|@)|\$\{(?:ARGUMENTS|@)\})\s*$/m.test(body)) {
13
+ return body;
14
+ }
15
+ return body.replace(COMMAND_CODE_INPUT_HEADING, (heading) => `${heading}\n**Provided arguments**: $ARGUMENTS`);
16
+ }
17
+ /**
18
+ * Command Code adapter for command generation.
19
+ * File path: .commandcode/commands/opsx-<id>.md
20
+ * Format: plain Markdown with $ARGUMENTS injected after the input contract
21
+ *
22
+ * Command Code executes the full trimmed file body and substitutes invocation
23
+ * arguments only where the body includes one of its argument placeholders.
24
+ */
25
+ export const commandCodeAdapter = {
26
+ toolId: 'command-code',
27
+ getFilePath(commandId) {
28
+ return path.join('.commandcode', 'commands', `opsx-${commandId}.md`);
29
+ },
30
+ formatFile(content) {
31
+ return `${injectCommandCodeArgs(content.body)}\n`;
32
+ },
33
+ };
34
+ //# sourceMappingURL=command-code.js.map
@@ -9,6 +9,7 @@ export { auggieAdapter } from './auggie.js';
9
9
  export { bobAdapter } from './bob.js';
10
10
  export { claudeAdapter } from './claude.js';
11
11
  export { clineAdapter } from './cline.js';
12
+ export { commandCodeAdapter } from './command-code.js';
12
13
  export { codebuddyAdapter } from './codebuddy.js';
13
14
  export { continueAdapter } from './continue.js';
14
15
  export { costrictAdapter } from './costrict.js';
@@ -9,6 +9,7 @@ export { auggieAdapter } from './auggie.js';
9
9
  export { bobAdapter } from './bob.js';
10
10
  export { claudeAdapter } from './claude.js';
11
11
  export { clineAdapter } from './cline.js';
12
+ export { commandCodeAdapter } from './command-code.js';
12
13
  export { codebuddyAdapter } from './codebuddy.js';
13
14
  export { continueAdapter } from './continue.js';
14
15
  export { costrictAdapter } from './costrict.js';
@@ -10,6 +10,7 @@ import { auggieAdapter } from './adapters/auggie.js';
10
10
  import { bobAdapter } from './adapters/bob.js';
11
11
  import { claudeAdapter } from './adapters/claude.js';
12
12
  import { clineAdapter } from './adapters/cline.js';
13
+ import { commandCodeAdapter } from './adapters/command-code.js';
13
14
  import { devinAdapter } from './adapters/devin.js';
14
15
  import { codebuddyAdapter } from './adapters/codebuddy.js';
15
16
  import { continueAdapter } from './adapters/continue.js';
@@ -45,6 +46,7 @@ export class CommandAdapterRegistry {
45
46
  CommandAdapterRegistry.register(bobAdapter);
46
47
  CommandAdapterRegistry.register(claudeAdapter);
47
48
  CommandAdapterRegistry.register(clineAdapter);
49
+ CommandAdapterRegistry.register(commandCodeAdapter);
48
50
  CommandAdapterRegistry.register(devinAdapter);
49
51
  CommandAdapterRegistry.register(codebuddyAdapter);
50
52
  CommandAdapterRegistry.register(continueAdapter);
@@ -97,6 +97,10 @@ export const COMMAND_REGISTRY = [
97
97
  name: 'specs',
98
98
  description: 'Validate all specs',
99
99
  },
100
+ {
101
+ name: 'archived',
102
+ description: 'Validate that archived changes have all tasks completed (for pre-commit linting)',
103
+ },
100
104
  COMMON_FLAGS.type,
101
105
  COMMON_FLAGS.strict,
102
106
  COMMON_FLAGS.jsonValidation,
@@ -226,6 +230,7 @@ export const COMMAND_REGISTRY = [
226
230
  description: 'List available workflow schemas with descriptions',
227
231
  flags: [
228
232
  COMMON_FLAGS.json,
233
+ COMMON_FLAGS.store,
229
234
  ],
230
235
  },
231
236
  {
@@ -17,6 +17,7 @@ export interface AIToolOption {
17
17
  globalSkillsDir?: string;
18
18
  detectionPaths?: string[];
19
19
  setupNote?: string;
20
+ requiresIdeRestart?: boolean;
20
21
  }
21
22
  export declare const AI_TOOLS: AIToolOption[];
22
23
  /**
@@ -18,41 +18,42 @@ export const OPENSPEC_MARKERS = {
18
18
  end: '<!-- OPENSPEC:END -->'
19
19
  };
20
20
  export const AI_TOOLS = [
21
- { name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq' },
22
- { name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent' },
21
+ { name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq', requiresIdeRestart: true },
22
+ { name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent', requiresIdeRestart: true },
23
23
  { name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment' },
24
24
  { name: 'Bob Shell', value: 'bob', available: true, successLabel: 'Bob Shell', skillsDir: '.bob' },
25
25
  { name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' },
26
- { name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline' },
26
+ { name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline', requiresIdeRestart: true },
27
+ { name: 'Command Code', value: 'command-code', available: true, successLabel: 'Command Code', skillsDir: '.commandcode' },
27
28
  { name: 'CodeArts', value: 'codeartsagent', available: true, successLabel: 'CodeArts', skillsDir: '.codeartsdoer' },
28
29
  { name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.agents', legacySkillsDirs: ['.codex'], detectionPaths: ['.agents/skills', '.codex/skills'] },
29
- { name: 'Devin Desktop (formerly Windsurf)', value: 'devin', available: true, successLabel: 'Devin Desktop', skillsDir: '.devin', detectionPaths: ['.devin', '.windsurf'] },
30
+ { name: 'Devin Desktop (formerly Windsurf)', value: 'devin', available: true, successLabel: 'Devin Desktop', skillsDir: '.devin', detectionPaths: ['.devin', '.windsurf'], requiresIdeRestart: true },
30
31
  { name: 'ForgeCode', value: 'forgecode', available: true, successLabel: 'ForgeCode', skillsDir: '.forge' },
31
32
  { name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code', skillsDir: '.codebuddy' },
32
- { name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue' },
33
- { name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec' },
33
+ { name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue', requiresIdeRestart: true },
34
+ { name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec', requiresIdeRestart: true },
34
35
  { name: 'Crush', value: 'crush', available: true, successLabel: 'Crush', skillsDir: '.crush' },
35
- { name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor' },
36
+ { name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor', requiresIdeRestart: true },
36
37
  { name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' },
37
38
  { name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' },
38
- { name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', detectionPaths: ['.github/copilot-instructions.md', '.github/instructions', '.github/workflows/copilot-setup-steps.yml', '.github/prompts', '.github/agents', '.github/skills', '.github/.mcp.json'] },
39
+ { name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', detectionPaths: ['.github/copilot-instructions.md', '.github/instructions', '.github/workflows/copilot-setup-steps.yml', '.github/prompts', '.github/agents', '.github/skills', '.github/.mcp.json'], requiresIdeRestart: true },
39
40
  { name: 'Hermes Agent', value: 'hermes', available: true, successLabel: 'Hermes Agent', skillsDir: '.hermes', detectionPaths: ['.hermes', 'HERMES.md', '.hermes.md'], setupNote: "Hermes only loads skills from ~/.hermes/skills by default. Add this project's .hermes/skills directory to skills.external_dirs in ~/.hermes/config.yaml so Hermes picks up the generated OpenSpec skills." },
40
41
  { name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },
41
- { name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie' },
42
- { name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' },
42
+ { name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie', requiresIdeRestart: true },
43
+ { name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode', requiresIdeRestart: true },
43
44
  { name: 'Kimi Code', value: 'kimi', available: true, successLabel: 'Kimi Code', skillsDir: '.kimi-code', detectionPaths: ['.kimi-code', '.kimi'] },
44
- { name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' },
45
- { name: 'Lingma', value: 'lingma', available: true, successLabel: 'Lingma', skillsDir: '.lingma' },
45
+ { name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro', requiresIdeRestart: true },
46
+ { name: 'Lingma', value: 'lingma', available: true, successLabel: 'Lingma', skillsDir: '.lingma', requiresIdeRestart: true },
46
47
  { name: 'MiniMax Code', value: 'minimax-code', available: true, successLabel: 'MiniMax Code', globalSkillsDir: '.minimax' },
47
48
  { name: 'Mistral Vibe', value: 'vibe', available: true, successLabel: 'Mistral Vibe', skillsDir: '.vibe' },
48
49
  { name: 'Oh My Pi', value: 'oh-my-pi', available: true, successLabel: 'Oh My Pi', skillsDir: '.omp' },
49
50
  { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' },
50
51
  { name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' },
51
- { name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' },
52
+ { name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder', requiresIdeRestart: true },
52
53
  { name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' },
53
54
  { name: 'Rovo Dev CLI', value: 'rovodev', available: true, successLabel: 'Rovo Dev CLI', skillsDir: '.rovodev', detectionPaths: ['.rovodev/skills', '.rovodev'] },
54
- { name: 'Zoo Code', value: 'roocode', available: true, successLabel: 'Zoo Code', skillsDir: '.roo' },
55
- { name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae' },
55
+ { name: 'Zoo Code', value: 'roocode', available: true, successLabel: 'Zoo Code', skillsDir: '.roo', requiresIdeRestart: true },
56
+ { name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae', requiresIdeRestart: true },
56
57
  { name: 'ZCode', value: 'zcode', available: true, successLabel: 'ZCode', skillsDir: '.zcode' },
57
58
  // Vendor-neutral target for assistants that read the shared `.agents` root.
58
59
  // Detection keys off `.agents/skills` rather than the bare root: frameworks use
package/dist/core/init.js CHANGED
@@ -544,6 +544,7 @@ export class InitCommand {
544
544
  skillsRoot: isGlobalSkillTarget ? skillsPath : projectPath,
545
545
  isGlobalSkillTarget,
546
546
  wasConfigured: preState?.configured ?? false,
547
+ requiresIdeRestart: tool.requiresIdeRestart,
547
548
  });
548
549
  }
549
550
  return validatedTools;
@@ -930,14 +931,25 @@ export class InitCommand {
930
931
  console.log();
931
932
  console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
932
933
  console.log(`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`);
933
- // Restart instruction if any tools were configured and got a surface
934
- // (when nothing was generated there is nothing a restart would pick up);
935
- // only mention commands when commands were actually generated. Not "slash
936
- // commands": Amazon Q's generated files are prompt-library entries invoked
937
- // with @, so a restart line promising slash commands would be wrong for it.
938
- if ((results.createdTools.length > 0 || results.refreshedTools.length > 0) && (commandsGenerated || skillsGenerated)) {
934
+ // Restart instruction only when at least one IDE/editor-resident tool
935
+ // actually received a generated surface. Two conditions, coupled to the SAME
936
+ // tool: (1) its commands/skills are loaded by a long-running editor process
937
+ // (CLI tools pick the files up immediately, so a restart line would be wrong
938
+ // for them — see #1067), and (2) a surface was actually generated for it
939
+ // under the active delivery (an IDE tool that generated nothing has nothing a
940
+ // restart would pick up, even if a co-configured CLI tool did generate).
941
+ // Wording follows what the IDE tool itself generated, not the global
942
+ // aggregate: it must not say "commands" when the IDE tool only got skills
943
+ // while a co-configured CLI tool got commands. Not "slash commands" either:
944
+ // Amazon Q's generated files are prompt-library entries invoked with @, so a
945
+ // restart line promising slash commands would be wrong for it.
946
+ const restartCommandsGenerated = successfulTools.some((tool) => tool.requiresIdeRestart &&
947
+ shouldGenerateCommandsForTool(tool.value, activeDelivery));
948
+ const restartSkillsGenerated = successfulTools.some((tool) => tool.requiresIdeRestart &&
949
+ shouldGenerateSkillsForTool(tool.value, activeDelivery));
950
+ if (restartCommandsGenerated || restartSkillsGenerated) {
939
951
  console.log();
940
- console.log(chalk.white(commandsGenerated
952
+ console.log(chalk.white(restartCommandsGenerated
941
953
  ? 'Restart your IDE for the new commands to take effect.'
942
954
  : 'Restart your IDE for the new skills to take effect.'));
943
955
  }
@@ -206,6 +206,16 @@ export declare function getLegacyWorkflowIdsForTool(detection: LegacyDetectionRe
206
206
  * callers can safely perform the immediate, non-deferred cleanup pass.
207
207
  */
208
208
  export declare function omitGlobalLegacyPromptFiles(detection: LegacyDetectionResult): LegacyDetectionResult;
209
+ /**
210
+ * Returns a detection snapshot with the repo-local slash-command artifacts of
211
+ * the given tools removed. The legacy-upgrade path uses this to skip cleaning a
212
+ * tool's legacy files when its replacement was deliberately NOT written — e.g. a
213
+ * Codex upgrade suppressed because the shared `.agents` root is already owned by
214
+ * another tool. Deleting the legacy prompt without writing its replacement would
215
+ * violate the cleanup contract ("remove X because replacement Y now exists") and
216
+ * strip the tool's only OpenSpec integration.
217
+ */
218
+ export declare function omitToolLegacyArtifacts(detection: LegacyDetectionResult, toolIds: readonly string[]): LegacyDetectionResult;
209
219
  /**
210
220
  * Builds a detection snapshot containing only the selected global Codex prompt
211
221
  * matches for replacement-gated cleanup.
@@ -614,41 +614,42 @@ export function formatDeferredGlobalPromptSummary(detection) {
614
614
  */
615
615
  export function getToolsFromLegacyArtifacts(detection) {
616
616
  const tools = new Set();
617
- // Match directories to tool IDs
618
617
  for (const dir of detection.slashCommandDirs) {
619
- for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
620
- if (pattern.type === 'directory' && pattern.path === dir) {
621
- tools.add(toolId);
622
- break;
623
- }
624
- }
618
+ const toolId = legacyToolIdForDir(dir);
619
+ if (toolId)
620
+ tools.add(toolId);
625
621
  }
626
- // Match files to tool IDs using glob patterns
627
622
  for (const file of detection.slashCommandFiles) {
628
- // Normalize file path to use forward slashes for consistent matching (Windows compatibility)
629
- const normalizedFile = normalizePathForMatch(file);
630
- for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
631
- if (pattern.type === 'files' && pattern.pattern) {
632
- const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
633
- let matched = false;
634
- for (const p of patterns) {
635
- const regex = globToRegex(p);
636
- if (regex.test(normalizedFile)) {
637
- tools.add(toolId);
638
- matched = true;
639
- break;
640
- }
641
- }
642
- if (matched)
643
- break;
644
- }
645
- }
623
+ const toolId = legacyToolIdForFile(file);
624
+ if (toolId)
625
+ tools.add(toolId);
646
626
  }
647
627
  for (const prompt of getLegacyGlobalPromptMatches(detection)) {
648
628
  tools.add(prompt.toolId);
649
629
  }
650
630
  return Array.from(tools);
651
631
  }
632
+ /** The tool that owns a repo-local legacy slash-command directory, if any. */
633
+ function legacyToolIdForDir(dir) {
634
+ for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
635
+ if (pattern.type === 'directory' && pattern.path === dir)
636
+ return toolId;
637
+ }
638
+ return undefined;
639
+ }
640
+ /** The tool that owns a repo-local legacy slash-command file, if any. */
641
+ function legacyToolIdForFile(file) {
642
+ // Normalize to forward slashes so the glob patterns match on Windows too.
643
+ const normalizedFile = normalizePathForMatch(file);
644
+ for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
645
+ if (pattern.type !== 'files' || !pattern.pattern)
646
+ continue;
647
+ const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
648
+ if (patterns.some((p) => globToRegex(p).test(normalizedFile)))
649
+ return toolId;
650
+ }
651
+ return undefined;
652
+ }
652
653
  /**
653
654
  * Normalizes global Codex prompt matches so callers can rely on workflow-aware
654
655
  * metadata even when older detection results only carry file paths.
@@ -699,6 +700,27 @@ export function omitGlobalLegacyPromptFiles(detection) {
699
700
  nextDetection.hasLegacyArtifacts = hasLegacyArtifacts(nextDetection);
700
701
  return nextDetection;
701
702
  }
703
+ /**
704
+ * Returns a detection snapshot with the repo-local slash-command artifacts of
705
+ * the given tools removed. The legacy-upgrade path uses this to skip cleaning a
706
+ * tool's legacy files when its replacement was deliberately NOT written — e.g. a
707
+ * Codex upgrade suppressed because the shared `.agents` root is already owned by
708
+ * another tool. Deleting the legacy prompt without writing its replacement would
709
+ * violate the cleanup contract ("remove X because replacement Y now exists") and
710
+ * strip the tool's only OpenSpec integration.
711
+ */
712
+ export function omitToolLegacyArtifacts(detection, toolIds) {
713
+ if (toolIds.length === 0)
714
+ return detection;
715
+ const skip = new Set(toolIds);
716
+ const nextDetection = {
717
+ ...detection,
718
+ slashCommandDirs: detection.slashCommandDirs.filter((dir) => !skip.has(legacyToolIdForDir(dir) ?? '')),
719
+ slashCommandFiles: detection.slashCommandFiles.filter((file) => !skip.has(legacyToolIdForFile(file) ?? '')),
720
+ };
721
+ nextDetection.hasLegacyArtifacts = hasLegacyArtifacts(nextDetection);
722
+ return nextDetection;
723
+ }
702
724
  /**
703
725
  * Builds a detection snapshot containing only the selected global Codex prompt
704
726
  * matches for replacement-gated cleanup.
@@ -1,4 +1,4 @@
1
- import { buildCodeFenceMask } from './requirement-text.js';
1
+ import { buildCodeFenceMask, SCENARIO_HEADER } from './requirement-text.js';
2
2
  export function normalizeRequirementName(name) {
3
3
  return name.trim();
4
4
  }
@@ -289,25 +289,57 @@ export function findMissingCurrentScenarios(current, incoming) {
289
289
  }
290
290
  return missing;
291
291
  }
292
+ /**
293
+ * Any non-fenced level-4 header on the given (masked) line. Reuses the spec
294
+ * path's SCENARIO_HEADER so the two counters cannot drift apart.
295
+ */
296
+ function scenarioHeaderAt(lines, mask, index) {
297
+ return !mask[index] && SCENARIO_HEADER.test(lines[index]);
298
+ }
299
+ /**
300
+ * The scenario name for a `#### ` header, matching the label the author reads:
301
+ * the header text with the leading `####`, an optional CommonMark closing `#`
302
+ * run (`#### Foo ####` renders as `Foo`), and an optional `Scenario:` prefix
303
+ * stripped. Both the current and incoming blocks run through here, so the
304
+ * comparison in findMissingCurrentScenarios stays internally consistent
305
+ * regardless of label — and two headers that render to the same title (one
306
+ * ATX-closed, one not) are not mistaken for a dropped scenario.
307
+ */
308
+ function scenarioNameAt(line) {
309
+ return line
310
+ .replace(SCENARIO_HEADER, '')
311
+ // Optional ATX closing sequence. CommonMark only treats a trailing `#` run
312
+ // as a close when it is preceded by a space or tab — not any Unicode space —
313
+ // so this uses `[ \t]`, not `\s`. A looser `\s` could strip a `#` run after
314
+ // an exotic space (e.g. NBSP) that CommonMark keeps, folding two distinct
315
+ // scenario names into one and masking a real loss. `[ \t]` keeps the fold
316
+ // faithful to how the header actually renders.
317
+ .replace(/[ \t]+#+[ \t]*$/, '')
318
+ .replace(/^Scenario:\s*/i, '')
319
+ .trim();
320
+ }
292
321
  function parseScenarioBlocks(requirementRaw) {
293
322
  const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n');
294
- // A `#### Scenario:` inside a fenced example is not a real scenario. The
295
- // validator's countScenarios already ignores fenced lines; the drift check
296
- // must agree with it, or a fenced sample can false-abort an archive (or
297
- // mask a genuinely dropped scenario).
323
+ // A scenario is ANY non-fenced `#### ` header, matching the spec path's
324
+ // SCENARIO_HEADER / countScenarios (requirement-text.ts) exactly — not only
325
+ // `#### Scenario:`. The two MUST agree: a level-4 child whose header is not
326
+ // literally `Scenario:` (e.g. `#### Edge case`) is still a scenario the spec
327
+ // path counts, so a MODIFIED block that drops it would otherwise slip past
328
+ // this loss check and be deleted by archive with no error (the parity the
329
+ // SCENARIO_HEADER comment warns not to break). A `####` inside a fenced
330
+ // example is masked out, matching countScenarios.
298
331
  const mask = buildCodeFenceMask(lines);
299
332
  const scenarios = [];
300
333
  let index = 0;
301
334
  while (index < lines.length) {
302
- const headerMatch = mask[index] ? null : lines[index].match(/^####\s*Scenario:\s*(.+)\s*$/);
303
- if (!headerMatch) {
335
+ if (!scenarioHeaderAt(lines, mask, index)) {
304
336
  index++;
305
337
  continue;
306
338
  }
307
339
  const start = index;
308
- const name = headerMatch[1].trim();
340
+ const name = scenarioNameAt(lines[index]);
309
341
  index++;
310
- while (index < lines.length && (mask[index] || !/^####\s*Scenario:\s*(.+)\s*$/.test(lines[index]))) {
342
+ while (index < lines.length && !scenarioHeaderAt(lines, mask, index)) {
311
343
  index++;
312
344
  }
313
345
  scenarios.push({
@@ -9,6 +9,14 @@
9
9
  * `validate <change>`, `validate <spec>`, and `archive`.
10
10
  */
11
11
  export { buildCodeFenceMask } from './code-fence.js';
12
+ /**
13
+ * A level-4 header. Deliberately matches ANY `####` header, not only
14
+ * `#### Scenario:` — the spec path treats every level-4 child of a requirement
15
+ * as a scenario, so the delta counter must too (parity). The delta/loss path
16
+ * reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
17
+ * keep both paths on it rather than reintroducing a separate `Scenario:` regex.
18
+ */
19
+ export declare const SCENARIO_HEADER: RegExp;
12
20
  /**
13
21
  * The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
14
22
  * as whole words so the change-delta reader and the schema-based reader accept
@@ -19,10 +19,11 @@ const HEADER_LINE = /^#{1,6}\s/;
19
19
  /**
20
20
  * A level-4 header. Deliberately matches ANY `####` header, not only
21
21
  * `#### Scenario:` — the spec path treats every level-4 child of a requirement
22
- * as a scenario, so the delta counter must too (parity). Don't tighten this to
23
- * `Scenario:` without changing both paths together.
22
+ * as a scenario, so the delta counter must too (parity). The delta/loss path
23
+ * reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
24
+ * keep both paths on it rather than reintroducing a separate `Scenario:` regex.
24
25
  */
25
- const SCENARIO_HEADER = /^####\s+/;
26
+ export const SCENARIO_HEADER = /^####\s+/;
26
27
  /**
27
28
  * The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
28
29
  * as whole words so the change-delta reader and the schema-based reader accept
@@ -119,7 +119,7 @@ export declare function toPlanningHome(root: ResolvedOpenSpecRoot): PlanningHome
119
119
  export declare function resolveRootForCommand(selector: StoreSelectorOptions, output?: {
120
120
  json?: boolean;
121
121
  failurePayload?: Record<string, unknown>;
122
- /** Diagnostic commands inspect what exists; they never scaffold. */
122
+ /** Commands that require an existing root set this to false. */
123
123
  allowImplicitRoot?: boolean;
124
124
  }): Promise<ResolvedOpenSpecRoot | null>;
125
125
  //# sourceMappingURL=root-selection.d.ts.map
@@ -12,5 +12,20 @@ export declare function reconcileSharedSkillTargets(projectPath: string, tools:
12
12
  * Non-shared roots are always active.
13
13
  */
14
14
  export declare function isSharedSkillTargetActive(projectPath: string, toolId: string): boolean;
15
+ /**
16
+ * The tool that already owns `toolId`'s shared skills root, when a DIFFERENT
17
+ * one does. Returns the owner's tool id only when the root already carries an
18
+ * ownership signal (a marker or generated skills) AND reconciliation resolves
19
+ * it to another tool. An empty or unclaimed root returns undefined, so a
20
+ * genuine first-time legacy upgrade — e.g. a Codex-only user with no `.agents`
21
+ * yet — is never reported as owned.
22
+ */
23
+ export declare function sharedSkillRootOwner(projectPath: string, toolId: string): string | undefined;
24
+ /**
25
+ * Whether generating `toolId` into its shared skills root would clobber a tree
26
+ * a DIFFERENT tool already owns — the guard the legacy-upgrade path uses before
27
+ * writing skills. See {@link sharedSkillRootOwner} for the ownership rules.
28
+ */
29
+ export declare function sharedSkillRootOwnedByOther(projectPath: string, toolId: string): boolean;
15
30
  export declare function writeSharedSkillTarget(projectPath: string, toolId: string): void;
16
31
  //# sourceMappingURL=shared-skill-target.d.ts.map
@@ -141,6 +141,36 @@ export function isSharedSkillTargetActive(projectPath, toolId) {
141
141
  return reconcileSharedSkillTargets(projectPath, sharingRoot)
142
142
  .some((candidate) => candidate.value === toolId);
143
143
  }
144
+ /**
145
+ * The tool that already owns `toolId`'s shared skills root, when a DIFFERENT
146
+ * one does. Returns the owner's tool id only when the root already carries an
147
+ * ownership signal (a marker or generated skills) AND reconciliation resolves
148
+ * it to another tool. An empty or unclaimed root returns undefined, so a
149
+ * genuine first-time legacy upgrade — e.g. a Codex-only user with no `.agents`
150
+ * yet — is never reported as owned.
151
+ */
152
+ export function sharedSkillRootOwner(projectPath, toolId) {
153
+ const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
154
+ if (!tool?.skillsDir)
155
+ return undefined;
156
+ const sharingRoot = AI_TOOLS.filter((candidate) => candidate.skillsDir === tool.skillsDir);
157
+ if (sharingRoot.length < 2)
158
+ return undefined;
159
+ const hasOwnerSignal = readSharedSkillTarget(projectPath, tool.skillsDir) !== undefined ||
160
+ hasCurrentSkills(projectPath, tool.skillsDir);
161
+ if (!hasOwnerSignal)
162
+ return undefined;
163
+ const owner = reconcileSharedSkillTargets(projectPath, sharingRoot)[0]?.value;
164
+ return owner && owner !== toolId ? owner : undefined;
165
+ }
166
+ /**
167
+ * Whether generating `toolId` into its shared skills root would clobber a tree
168
+ * a DIFFERENT tool already owns — the guard the legacy-upgrade path uses before
169
+ * writing skills. See {@link sharedSkillRootOwner} for the ownership rules.
170
+ */
171
+ export function sharedSkillRootOwnedByOther(projectPath, toolId) {
172
+ return sharedSkillRootOwner(projectPath, toolId) !== undefined;
173
+ }
144
174
  export function writeSharedSkillTarget(projectPath, toolId) {
145
175
  const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
146
176
  if (!tool?.skillsDir)
@@ -404,10 +404,17 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
404
404
  .concat(keptOrder.map((b) => b.raw))
405
405
  .join('\n\n')
406
406
  .trimEnd();
407
- const rebuilt = [parts.before.trimEnd(), parts.headerLine, reqBody, parts.after]
408
- .filter((s, idx) => !(idx === 0 && s === ''))
409
- .join('\n')
410
- .replace(/\n{3,}/g, '\n\n');
407
+ // The blank lines surrounding `## Requirements` belong to no slice: `before`
408
+ // and `after` carry at most one trailing/leading '\n' by construction, and
409
+ // the body is trimEnd()ed. Joining the slices with a bare '\n' therefore
410
+ // glued the heading to the Purpose paragraph and the first requirement, so
411
+ // every archive rewrote a well-formatted spec into that shape. Separate
412
+ // non-empty slices with one blank line instead.
413
+ const rebuilt = [parts.before.trimEnd(), parts.headerLine, reqBody, parts.after.trim()]
414
+ .filter((s) => s !== '')
415
+ .join('\n\n')
416
+ .replace(/\n{3,}/g, '\n\n')
417
+ .trimEnd() + '\n';
411
418
  return {
412
419
  rebuilt,
413
420
  counts: {