@fission-ai/openspec 1.11.0 → 1.13.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 (37) hide show
  1. package/README.md +3 -14
  2. package/dist/cli/index.js +1 -0
  3. package/dist/commands/change.js +6 -5
  4. package/dist/commands/validate.d.ts +57 -0
  5. package/dist/commands/validate.js +99 -13
  6. package/dist/commands/workflow/instructions.js +140 -5
  7. package/dist/commands/workflow/shared.d.ts +8 -0
  8. package/dist/core/command-generation/adapters/codeassistant.d.ts +15 -0
  9. package/dist/core/command-generation/adapters/codeassistant.js +29 -0
  10. package/dist/core/command-generation/adapters/index.d.ts +1 -0
  11. package/dist/core/command-generation/adapters/index.js +1 -0
  12. package/dist/core/command-generation/registry.js +2 -0
  13. package/dist/core/completions/command-registry.js +6 -0
  14. package/dist/core/config.js +1 -0
  15. package/dist/core/init.d.ts +1 -0
  16. package/dist/core/init.js +41 -34
  17. package/dist/core/onboarding-commands.d.ts +11 -1
  18. package/dist/core/onboarding-commands.js +26 -0
  19. package/dist/core/openspec-root.d.ts +1 -0
  20. package/dist/core/openspec-root.js +10 -2
  21. package/dist/core/parsers/requirement-blocks.js +77 -31
  22. package/dist/core/shared/ide-restart.d.ts +26 -0
  23. package/dist/core/shared/ide-restart.js +42 -0
  24. package/dist/core/shared/index.d.ts +1 -0
  25. package/dist/core/shared/index.js +1 -0
  26. package/dist/core/shared/tool-detection.js +28 -1
  27. package/dist/core/specs-apply.js +184 -8
  28. package/dist/core/templates/workflows/explore.js +45 -0
  29. package/dist/core/templates/workflows/ff-change.js +8 -0
  30. package/dist/core/templates/workflows/propose.js +40 -12
  31. package/dist/core/update.d.ts +22 -0
  32. package/dist/core/update.js +55 -17
  33. package/dist/core/validation/validator.d.ts +12 -3
  34. package/dist/core/validation/validator.js +74 -3
  35. package/dist/utils/command-references.js +7 -8
  36. package/package.json +3 -3
  37. package/schemas/spec-driven/schema.yaml +14 -2
@@ -78,6 +78,7 @@ export declare class InitCommand {
78
78
  private resolveToolsArg;
79
79
  private validateTools;
80
80
  private createDirectoryStructure;
81
+ private writeGitkeepFiles;
81
82
  /**
82
83
  * Generates skill files and slash commands for each selected tool,
83
84
  * honoring the configured delivery mode (skills, commands, or both).
package/dist/core/init.js CHANGED
@@ -12,6 +12,7 @@ import { createRequire } from 'module';
12
12
  import { FileSystemUtils } from '../utils/file-system.js';
13
13
  import { classifyOpenSpecDir, MAX_CONTEXT_SIZE, readProjectConfig, storePointerProblem, } from './project-config.js';
14
14
  import { findRepoPlanningRootSync } from './planning-home.js';
15
+ import { ANCHORED_OPENSPEC_DIRS, ensureDirectoryAnchor } from './openspec-root.js';
15
16
  import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js';
16
17
  import { AI_TOOLS, OPENSPEC_DIR_NAME, resolveToolIdAlias, } from './config.js';
17
18
  import { PALETTE } from './styles/palette.js';
@@ -19,10 +20,11 @@ import { isInteractive } from '../utils/interactive.js';
19
20
  import { serializeConfig } from './config-prompts.js';
20
21
  import { generateCommands, CommandAdapterRegistry, } from './command-generation/index.js';
21
22
  import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatCleanupSummary, formatDeferredGlobalPromptSummary, formatDetectionSummary, getLegacyGlobalPromptMatches, omitGlobalLegacyPromptFiles, pickGlobalLegacyPromptFiles, } from './legacy-cleanup.js';
22
- import { getToolsWithSkillsDir, getToolStates, getSkillTemplates, getCommandContents, generateSkillContent, hasGlobalSkillTarget, resolveToolSkillsDir, toolSupportsSkills, } from './shared/index.js';
23
+ import { getToolsWithSkillsDir, getToolStates, getSkillTemplates, getCommandContents, generateSkillContent, hasGlobalSkillTarget, resolveToolSkillsDir, toolSupportsSkills, formatIdeRestart, } from './shared/index.js';
23
24
  import { getGlobalConfig } from './global-config.js';
24
25
  import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js';
25
26
  import { getAvailableTools } from './available-tools.js';
27
+ import { formatOptionalWorkflowsNote } from './onboarding-commands.js';
26
28
  import { resolveSharedSkillWriters, sharedSkillRootOwner, writeSharedSkillTarget, } from './shared-skill-target.js';
27
29
  import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptInPlaceNotice, hasMovableContent, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js';
28
30
  import { resolveCommandSurfaceCapability, resolveCommandInvocation, shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js';
@@ -590,7 +592,6 @@ export class InitCommand {
590
592
  skillsRoot: isGlobalSkillTarget ? skillsPath : projectPath,
591
593
  isGlobalSkillTarget,
592
594
  wasConfigured: preState?.configured ?? false,
593
- requiresIdeRestart: tool.requiresIdeRestart,
594
595
  writesSkills: !tool.skillsDir || skillWriters.has(tool.value),
595
596
  });
596
597
  }
@@ -600,36 +601,37 @@ export class InitCommand {
600
601
  // DIRECTORY STRUCTURE
601
602
  // ═══════════════════════════════════════════════════════════
602
603
  async createDirectoryStructure(openspecPath, extendMode) {
604
+ const directories = [
605
+ openspecPath,
606
+ path.join(openspecPath, 'specs'),
607
+ path.join(openspecPath, 'changes'),
608
+ path.join(openspecPath, 'changes', 'archive'),
609
+ ];
603
610
  if (extendMode) {
604
611
  // In extend mode, just ensure directories exist without spinner
605
- const directories = [
606
- openspecPath,
607
- path.join(openspecPath, 'specs'),
608
- path.join(openspecPath, 'changes'),
609
- path.join(openspecPath, 'changes', 'archive'),
610
- ];
611
612
  for (const dir of directories) {
612
613
  FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
613
614
  await FileSystemUtils.createDirectory(dir);
614
615
  }
616
+ await this.writeGitkeepFiles(openspecPath);
615
617
  return;
616
618
  }
617
619
  const spinner = this.startSpinner('Creating OpenSpec structure...');
618
- const directories = [
619
- openspecPath,
620
- path.join(openspecPath, 'specs'),
621
- path.join(openspecPath, 'changes'),
622
- path.join(openspecPath, 'changes', 'archive'),
623
- ];
624
620
  for (const dir of directories) {
625
621
  FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
626
622
  await FileSystemUtils.createDirectory(dir);
627
623
  }
624
+ await this.writeGitkeepFiles(openspecPath);
628
625
  spinner.stopAndPersist({
629
626
  symbol: PALETTE.white('▌'),
630
627
  text: PALETTE.white('OpenSpec structure created'),
631
628
  });
632
629
  }
630
+ async writeGitkeepFiles(openspecPath) {
631
+ for (const relativeDir of ANCHORED_OPENSPEC_DIRS) {
632
+ await ensureDirectoryAnchor(path.dirname(openspecPath), relativeDir);
633
+ }
634
+ }
633
635
  // ═══════════════════════════════════════════════════════════
634
636
  // SKILL & COMMAND GENERATION
635
637
  // ═══════════════════════════════════════════════════════════
@@ -1017,9 +1019,11 @@ export class InitCommand {
1017
1019
  `${zeroArtifactTools.length === 1 ? 'it supports' : 'they support'} only skills. ` +
1018
1020
  `Run 'openspec config set delivery both' to generate skills.`));
1019
1021
  }
1022
+ let advertisedAnInvocation = true;
1020
1023
  if (successfulTools.length > 0 && !commandsGenerated && !skillsGenerated) {
1021
1024
  // Nothing was generated for any tool: the correction above is the
1022
1025
  // whole story, so don't advertise an invocation that doesn't exist.
1026
+ advertisedAnInvocation = false;
1023
1027
  }
1024
1028
  else if (activeWorkflows.includes('propose')) {
1025
1029
  printStartHints('/opsx:propose');
@@ -1029,32 +1033,35 @@ export class InitCommand {
1029
1033
  }
1030
1034
  else {
1031
1035
  console.log("Done. Run 'openspec config profile' to configure your workflows.");
1036
+ advertisedAnInvocation = false;
1037
+ }
1038
+ // Workflows the active profile left out. Setup is the only moment a user
1039
+ // is told what exists, so name them here rather than let a missing
1040
+ // command read as a broken install (#1076). Skipped when the branch above
1041
+ // already pointed at `openspec config profile`, and when no tool received
1042
+ // a workflow surface at all (no tools selected, or none that could take
1043
+ // one) — there, adding workflows writes nothing, so naming them would
1044
+ // point at the wrong problem.
1045
+ if (advertisedAnInvocation && (commandsGenerated || skillsGenerated)) {
1046
+ const optionalWorkflowsNote = formatOptionalWorkflowsNote(activeWorkflows);
1047
+ if (optionalWorkflowsNote) {
1048
+ console.log();
1049
+ for (const line of optionalWorkflowsNote) {
1050
+ console.log(chalk.dim(line));
1051
+ }
1052
+ }
1032
1053
  }
1033
1054
  // Links
1034
1055
  console.log();
1035
1056
  console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
1036
1057
  console.log(`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`);
1037
- // Restart instruction only when at least one IDE/editor-resident tool
1038
- // actually received a generated surface. Two conditions, coupled to the SAME
1039
- // tool: (1) its commands/skills are loaded by a long-running editor process
1040
- // (CLI tools pick the files up immediately, so a restart line would be wrong
1041
- // for them — see #1067), and (2) a surface was actually generated for it
1042
- // under the active delivery (an IDE tool that generated nothing has nothing a
1043
- // restart would pick up, even if a co-configured CLI tool did generate).
1044
- // Wording follows what the IDE tool itself generated, not the global
1045
- // aggregate: it must not say "commands" when the IDE tool only got skills
1046
- // while a co-configured CLI tool got commands. Not "slash commands" either:
1047
- // Amazon Q's generated files are prompt-library entries invoked with @, so a
1048
- // restart line promising slash commands would be wrong for it.
1049
- const restartCommandsGenerated = successfulTools.some((tool) => tool.requiresIdeRestart &&
1050
- shouldGenerateCommandsForTool(tool.value, activeDelivery));
1051
- const restartSkillsGenerated = successfulTools.some((tool) => tool.requiresIdeRestart &&
1052
- shouldGenerateSkillsForTool(tool.value, activeDelivery));
1053
- if (restartCommandsGenerated || restartSkillsGenerated) {
1058
+ // Restart instruction for successfully configured IDE/editor-resident tools
1059
+ // with a supported surface under the active delivery. The rule and wording live in
1060
+ // formatIdeRestart so `update` says the same thing for the same event.
1061
+ const restartHint = formatIdeRestart(successfulTools.map((tool) => tool.value), activeDelivery);
1062
+ if (restartHint) {
1054
1063
  console.log();
1055
- console.log(chalk.white(restartCommandsGenerated
1056
- ? 'Restart your IDE for the new commands to take effect.'
1057
- : 'Restart your IDE for the new skills to take effect.'));
1064
+ console.log(chalk.white(restartHint));
1058
1065
  }
1059
1066
  console.log();
1060
1067
  }
@@ -9,7 +9,7 @@
9
9
  * tool — command, skill, or a tool-specific skill prefix — is decided by
10
10
  * src/utils/command-references.ts at the call site.
11
11
  */
12
- import type { WorkflowId } from './profiles.js';
12
+ import { type WorkflowId } from './profiles.js';
13
13
  export type OnboardingCommand = {
14
14
  workflow: WorkflowId;
15
15
  command: string;
@@ -27,4 +27,14 @@ export declare const DESCRIPTION_BUDGET = 17;
27
27
  * Returns an empty array when none of the onboarding workflows are installed.
28
28
  */
29
29
  export declare function getOnboardingCommands(workflows: readonly string[]): OnboardingCommand[];
30
+ /**
31
+ * Returns the note telling a user which workflows their profile left out, or
32
+ * null when every workflow is already installed.
33
+ *
34
+ * Setup output otherwise never names the workflows that exist but were not
35
+ * installed, so a user on the default profile has no way to learn that
36
+ * `/opsx:ff` and friends are one command away. The docs say it; nobody reads
37
+ * the docs before typing a command that isn't there.
38
+ */
39
+ export declare function formatOptionalWorkflowsNote(installedWorkflows: readonly string[]): string[] | null;
30
40
  //# sourceMappingURL=onboarding-commands.d.ts.map
@@ -9,6 +9,7 @@
9
9
  * tool — command, skill, or a tool-specific skill prefix — is decided by
10
10
  * src/utils/command-references.ts at the call site.
11
11
  */
12
+ import { ALL_WORKFLOWS } from './profiles.js';
12
13
  /**
13
14
  * Longest description the welcome screen can render. It shows these beside a
14
15
  * 24-column art column and only animates at MIN_WIDTH (60) columns or wider; a
@@ -35,4 +36,29 @@ export function getOnboardingCommands(workflows) {
35
36
  const installed = new Set(workflows);
36
37
  return ONBOARDING_COMMANDS.filter((entry) => installed.has(entry.workflow));
37
38
  }
39
+ /**
40
+ * Returns the note telling a user which workflows their profile left out, or
41
+ * null when every workflow is already installed.
42
+ *
43
+ * Setup output otherwise never names the workflows that exist but were not
44
+ * installed, so a user on the default profile has no way to learn that
45
+ * `/opsx:ff` and friends are one command away. The docs say it; nobody reads
46
+ * the docs before typing a command that isn't there.
47
+ */
48
+ export function formatOptionalWorkflowsNote(installedWorkflows) {
49
+ const installed = new Set(installedWorkflows);
50
+ const missing = ALL_WORKFLOWS.filter((workflow) => !installed.has(workflow));
51
+ if (missing.length === 0) {
52
+ return null;
53
+ }
54
+ const label = missing.length === 1 ? 'workflow is' : 'workflows are';
55
+ const pronoun = missing.length === 1 ? 'it' : 'them';
56
+ // `openspec config profile` offers to apply to this project before it
57
+ // exits, and prints the `openspec update` guidance itself when declined, so
58
+ // naming a second command here would be one step too many.
59
+ return [
60
+ `Note: ${missing.length} more ${label} available (${missing.join(', ')}).`,
61
+ `Add ${pronoun} with \`openspec config profile\`.`,
62
+ ];
63
+ }
38
64
  //# sourceMappingURL=onboarding-commands.js.map
@@ -37,6 +37,7 @@ export interface EnsureOpenSpecRootResult {
37
37
  createdPaths: CreatedPathLedgerEntry[];
38
38
  }
39
39
  export declare function inspectOpenSpecRoot(storeRoot: string): Promise<OpenSpecRootInspection>;
40
+ export declare function ensureDirectoryAnchor(storeRoot: string, relativeDir: string, ledger?: CreatedPathLedgerEntry[]): Promise<void>;
40
41
  export interface EnsureOpenSpecRootOptions {
41
42
  anchorEmptyDirectories?: boolean;
42
43
  }
@@ -144,13 +144,21 @@ async function ensureDefaultConfig(storeRoot, ledger) {
144
144
  kind: 'file',
145
145
  });
146
146
  }
147
- async function ensureDirectoryAnchor(storeRoot, relativeDir, ledger) {
147
+ export async function ensureDirectoryAnchor(storeRoot, relativeDir, ledger = []) {
148
148
  const directory = path.join(storeRoot, relativeDir);
149
149
  if ((await fs.readdir(directory)).length > 0)
150
150
  return;
151
151
  const relativePath = `${relativeDir}/${DIRECTORY_ANCHOR_FILE_NAME}`;
152
152
  const absolutePath = path.join(directory, DIRECTORY_ANCHOR_FILE_NAME);
153
- await fs.writeFile(absolutePath, '', 'utf-8');
153
+ try {
154
+ // A file or symlink may appear after readdir. Never replace or follow it.
155
+ await fs.writeFile(absolutePath, '', { encoding: 'utf-8', flag: 'wx' });
156
+ }
157
+ catch (error) {
158
+ if (error.code === 'EEXIST')
159
+ return;
160
+ throw error;
161
+ }
154
162
  ledger.push({
155
163
  relativePath: relativeArtifact(relativePath, 'file'),
156
164
  absolutePath,
@@ -99,24 +99,26 @@ export function parseDeltaSpec(content) {
99
99
  const lines = normalized.split('\n');
100
100
  const fenceMask = buildCodeFenceMask(lines);
101
101
  const sections = splitTopLevelSections(lines, fenceMask);
102
- const addedLookup = getSectionCaseInsensitive(sections, 'ADDED Requirements');
103
- const modifiedLookup = getSectionCaseInsensitive(sections, 'MODIFIED Requirements');
104
- const removedLookup = getSectionCaseInsensitive(sections, 'REMOVED Requirements');
105
- const renamedLookup = getSectionCaseInsensitive(sections, 'RENAMED Requirements');
102
+ const addedLookup = getSectionsCaseInsensitive(sections, 'ADDED Requirements');
103
+ const modifiedLookup = getSectionsCaseInsensitive(sections, 'MODIFIED Requirements');
104
+ const removedLookup = getSectionsCaseInsensitive(sections, 'REMOVED Requirements');
105
+ const renamedLookup = getSectionsCaseInsensitive(sections, 'RENAMED Requirements');
106
106
  const skippedHeaders = [];
107
- const added = parseRequirementBlocksFromSection(addedLookup.body, {
107
+ const added = addedLookup.bodies.flatMap((body) => parseRequirementBlocksFromSection(body, {
108
108
  section: addedLookup.title,
109
- bodyStartLine: addedLookup.bodyStartLine,
109
+ bodyStartLine: body.bodyStartLine,
110
110
  sink: skippedHeaders,
111
- });
112
- const modified = parseRequirementBlocksFromSection(modifiedLookup.body, {
111
+ }));
112
+ const modified = modifiedLookup.bodies.flatMap((body) => parseRequirementBlocksFromSection(body, {
113
113
  section: modifiedLookup.title,
114
- bodyStartLine: modifiedLookup.bodyStartLine,
114
+ bodyStartLine: body.bodyStartLine,
115
115
  sink: skippedHeaders,
116
- });
117
- const removedNames = parseRemovedNames(removedLookup.body);
118
- const removedBlocks = parseRequirementBlocksFromSection(removedLookup.body);
119
- const renamedPairs = parseRenamedPairs(renamedLookup.body);
116
+ }));
117
+ const removedNames = removedLookup.bodies.flatMap((body) => parseRemovedNames(body));
118
+ const removedBlocks = removedLookup.bodies.flatMap((body) => parseRequirementBlocksFromSection(body));
119
+ // Pairs are read per section, so a FROM in one copy of the header can never
120
+ // pair with a TO in another.
121
+ const renamedPairs = renamedLookup.bodies.flatMap((body) => parseRenamedPairs(body));
120
122
  skippedHeaders.sort((a, b) => a.line - b.line);
121
123
  return {
122
124
  added,
@@ -133,8 +135,16 @@ export function parseDeltaSpec(content) {
133
135
  },
134
136
  };
135
137
  }
138
+ /**
139
+ * Every `## ` section, as a LIST rather than a title-keyed record.
140
+ *
141
+ * Keying by title silently dropped a repeated header: a delta that wrote
142
+ * `## ADDED Requirements` twice kept only the last body, so every requirement
143
+ * under the first copy was discarded before any validation or merge rule could
144
+ * see it. A list keeps each occurrence, and the lookup below merges them.
145
+ */
136
146
  function splitTopLevelSections(lines, fenceMask) {
137
- const result = {};
147
+ const sections = [];
138
148
  const indices = [];
139
149
  for (let i = 0; i < lines.length; i++) {
140
150
  if (fenceMask[i])
@@ -148,23 +158,39 @@ function splitTopLevelSections(lines, fenceMask) {
148
158
  const current = indices[i];
149
159
  const next = indices[i + 1];
150
160
  const end = next ? next.index : lines.length;
151
- result[current.title] = {
152
- lines: lines.slice(current.index + 1, end),
153
- fenceMask: fenceMask.slice(current.index + 1, end),
154
- bodyStartLine: current.index + 2,
155
- };
161
+ sections.push({
162
+ title: current.title,
163
+ body: {
164
+ lines: lines.slice(current.index + 1, end),
165
+ fenceMask: fenceMask.slice(current.index + 1, end),
166
+ bodyStartLine: current.index + 2,
167
+ },
168
+ });
156
169
  }
157
- return result;
170
+ return sections;
158
171
  }
159
- const EMPTY_SECTION_BODY = { lines: [], fenceMask: [], bodyStartLine: 0 };
160
- function getSectionCaseInsensitive(sections, desired) {
172
+ /**
173
+ * Every section body whose title folds to `desired`, in document order.
174
+ *
175
+ * Returning all of them - rather than the first match - is what makes a
176
+ * repeated header (`## ADDED Requirements` twice) and a case variant
177
+ * (`## ADDED Requirements` + `## Added Requirements`) both apply in full. Each
178
+ * body keeps its own `bodyStartLine`, so reported line numbers stay correct for
179
+ * the copy the header actually came from.
180
+ *
181
+ * `title` is the first spelling the author used, which is what diagnostics quote.
182
+ */
183
+ function getSectionsCaseInsensitive(sections, desired) {
161
184
  const target = desired.toLowerCase();
162
- for (const [title, body] of Object.entries(sections)) {
163
- if (title.toLowerCase() === target) {
164
- return { title, body, bodyStartLine: body.bodyStartLine, found: true };
165
- }
185
+ const matches = sections.filter((section) => section.title.toLowerCase() === target);
186
+ if (matches.length === 0) {
187
+ return { title: desired, bodies: [], found: false };
166
188
  }
167
- return { title: desired, body: EMPTY_SECTION_BODY, bodyStartLine: 0, found: false };
189
+ return {
190
+ title: matches[0].title,
191
+ bodies: matches.map((section) => section.body),
192
+ found: true,
193
+ };
168
194
  }
169
195
  function parseRequirementBlocksFromSection(sectionBody, skipped) {
170
196
  const { lines, fenceMask } = sectionBody;
@@ -212,6 +238,13 @@ function parseRequirementBlocksFromSection(sectionBody, skipped) {
212
238
  }
213
239
  return blocks;
214
240
  }
241
+ /**
242
+ * Requirement names listed in `## REMOVED Requirements`, in document order.
243
+ *
244
+ * Two spellings are accepted: a plain `### Requirement:` header, and a bullet
245
+ * carrying one. Every CommonMark bullet marker counts for the second form -
246
+ * see the pattern below for why that matters.
247
+ */
215
248
  function parseRemovedNames(sectionBody) {
216
249
  const { lines, fenceMask } = sectionBody;
217
250
  if (lines.length === 0)
@@ -226,14 +259,24 @@ function parseRemovedNames(sectionBody) {
226
259
  names.push(normalizeRequirementName(m[1]));
227
260
  continue;
228
261
  }
229
- // Also support bullet list of headers
230
- const bullet = line.match(/^\s*-\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
262
+ // Also support bullet list of headers. Every CommonMark bullet marker
263
+ // counts: `*` and `+` open a list exactly as `-` does, so accepting only
264
+ // `-` turned a removal written with either of them into a silent no-op -
265
+ // archive reported success while the requirement stayed in the spec.
266
+ const bullet = line.match(/^\s*[-*+]\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
231
267
  if (bullet) {
232
268
  names.push(normalizeRequirementName(bullet[1]));
233
269
  }
234
270
  }
235
271
  return names;
236
272
  }
273
+ /**
274
+ * `FROM:`/`TO:` rename pairs from `## RENAMED Requirements`, in document order.
275
+ *
276
+ * The bullet is optional, and every CommonMark bullet marker is accepted: a
277
+ * rename written with `*` or `+` used to match nothing at all, so the rename
278
+ * silently never happened while archive still reported success.
279
+ */
237
280
  function parseRenamedPairs(sectionBody) {
238
281
  const { lines, fenceMask } = sectionBody;
239
282
  if (lines.length === 0)
@@ -244,8 +287,11 @@ function parseRenamedPairs(sectionBody) {
244
287
  if (fenceMask[i])
245
288
  continue;
246
289
  const line = lines[i];
247
- const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
248
- const toMatch = line.match(/^\s*-?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
290
+ // The bullet stays optional, and any CommonMark marker is accepted: a rename
291
+ // written with `*` or `+` used to match nothing at all, so the rename never
292
+ // happened while archive still reported success.
293
+ const fromMatch = line.match(/^\s*[-*+]?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
294
+ const toMatch = line.match(/^\s*[-*+]?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
249
295
  if (fromMatch) {
250
296
  current.from = normalizeRequirementName(fromMatch[1]);
251
297
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * IDE restart hint
3
+ *
4
+ * Shared restart guidance for tools successfully configured by init or update.
5
+ * The wording covers additions, updates, and removals, including an empty
6
+ * workflow selection that removes every generated file.
7
+ */
8
+ import type { Delivery } from '../global-config.js';
9
+ /** The surface a restart hint names. Absent when no hint is due. */
10
+ export type IdeRestartSurface = 'commands' | 'skills';
11
+ /**
12
+ * Both conditions stay coupled to the SAME tool: its surfaces are loaded by a
13
+ * long-running editor process (a CLI picks them up immediately, so a restart
14
+ * line would be wrong for it — see #1067), and it supports a generated surface
15
+ * under the active delivery. A CLI tool's commands must not determine the hint
16
+ * for an IDE tool that only supports skills. Commands take precedence when
17
+ * both surfaces are supported under the active delivery.
18
+ */
19
+ export declare function resolveIdeRestartSurface(toolIds: readonly string[], delivery: Delivery): IdeRestartSurface | null;
20
+ /**
21
+ * The restart line to print, or null when no restart is needed. Deliberately
22
+ * not "slash commands": Amazon Q's generated files are prompt-library entries
23
+ * invoked with `@`, so promising slash commands would be wrong for it.
24
+ */
25
+ export declare function formatIdeRestart(toolIds: readonly string[], delivery: Delivery): string | null;
26
+ //# sourceMappingURL=ide-restart.d.ts.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * IDE restart hint
3
+ *
4
+ * Shared restart guidance for tools successfully configured by init or update.
5
+ * The wording covers additions, updates, and removals, including an empty
6
+ * workflow selection that removes every generated file.
7
+ */
8
+ import { AI_TOOLS } from '../config.js';
9
+ import { shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, } from '../command-surface.js';
10
+ function isIdeResident(toolId) {
11
+ return Boolean(AI_TOOLS.find((tool) => tool.value === toolId)?.requiresIdeRestart);
12
+ }
13
+ /**
14
+ * Both conditions stay coupled to the SAME tool: its surfaces are loaded by a
15
+ * long-running editor process (a CLI picks them up immediately, so a restart
16
+ * line would be wrong for it — see #1067), and it supports a generated surface
17
+ * under the active delivery. A CLI tool's commands must not determine the hint
18
+ * for an IDE tool that only supports skills. Commands take precedence when
19
+ * both surfaces are supported under the active delivery.
20
+ */
21
+ export function resolveIdeRestartSurface(toolIds, delivery) {
22
+ const ideTools = [...new Set(toolIds)].filter(isIdeResident);
23
+ if (ideTools.some((toolId) => shouldGenerateCommandsForTool(toolId, delivery))) {
24
+ return 'commands';
25
+ }
26
+ if (ideTools.some((toolId) => shouldGenerateSkillsForTool(toolId, delivery))) {
27
+ return 'skills';
28
+ }
29
+ return null;
30
+ }
31
+ /**
32
+ * The restart line to print, or null when no restart is needed. Deliberately
33
+ * not "slash commands": Amazon Q's generated files are prompt-library entries
34
+ * invoked with `@`, so promising slash commands would be wrong for it.
35
+ */
36
+ export function formatIdeRestart(toolIds, delivery) {
37
+ const surface = resolveIdeRestartSurface(toolIds, delivery);
38
+ return surface
39
+ ? `Restart your IDE to refresh ${surface}.`
40
+ : null;
41
+ }
42
+ //# sourceMappingURL=ide-restart.js.map
@@ -6,4 +6,5 @@
6
6
  export { SKILL_NAMES, type SkillName, COMMAND_IDS, type CommandId, type ToolSkillStatus, type ToolVersionStatus, getToolsWithSkillsDir, getToolSkillStatus, getToolStates, extractGeneratedByVersion, getToolVersionStatus, getConfiguredTools, getAllToolVersionStatus, } from './tool-detection.js';
7
7
  export { type SkillTemplateEntry, type CommandTemplateEntry, getSkillTemplates, getCommandTemplates, getCommandContents, generateSkillContent, } from './skill-generation.js';
8
8
  export { type SkillCapableTool, toolSupportsSkills, getSkillCapableTools, hasGlobalSkillTarget, resolveToolSkillsDir, } from './skill-paths.js';
9
+ export { type IdeRestartSurface, resolveIdeRestartSurface, formatIdeRestart, } from './ide-restart.js';
9
10
  //# sourceMappingURL=index.d.ts.map
@@ -6,4 +6,5 @@
6
6
  export { SKILL_NAMES, COMMAND_IDS, getToolsWithSkillsDir, getToolSkillStatus, getToolStates, extractGeneratedByVersion, getToolVersionStatus, getConfiguredTools, getAllToolVersionStatus, } from './tool-detection.js';
7
7
  export { getSkillTemplates, getCommandTemplates, getCommandContents, generateSkillContent, } from './skill-generation.js';
8
8
  export { toolSupportsSkills, getSkillCapableTools, hasGlobalSkillTarget, resolveToolSkillsDir, } from './skill-paths.js';
9
+ export { resolveIdeRestartSurface, formatIdeRestart, } from './ide-restart.js';
9
10
  //# sourceMappingURL=index.js.map
@@ -253,7 +253,34 @@ export function getToolVersionStatus(projectRoot, toolId, currentVersion, option
253
253
  generatedByVersion = currentVersion;
254
254
  }
255
255
  }
256
- const needsUpdate = configured && (generatedByVersion === null || generatedByVersion !== currentVersion);
256
+ // 3. A version marker in a skill file only proves the SKILL files came from
257
+ // this CLI. It says nothing about the command files written beside them,
258
+ // which a user may have hand-edited or a partial write may have truncated.
259
+ // Without this, `update` answered "all tools up to date" while a damaged
260
+ // command file sat on disk, repairable only by knowing to pass --force.
261
+ // The content comparison already exists; it was simply never consulted
262
+ // once a skill file supplied a version.
263
+ //
264
+ // Scoped to tools that have BOTH, so the commands-only path above keeps
265
+ // its exact behaviour, and skipped when the delivery mode generates no
266
+ // commands for this tool - there would be nothing to compare against, and
267
+ // `areCommandFilesUpToDate` reports an empty command set as "not current".
268
+ let commandsDrifted = false;
269
+ if (skillConfigured && commandConfigured) {
270
+ let generatesCommands = true;
271
+ try {
272
+ generatesCommands = shouldGenerateCommandsForTool(toolId, getGlobalConfig().delivery ?? 'both');
273
+ }
274
+ catch {
275
+ generatesCommands = true;
276
+ }
277
+ commandsDrifted =
278
+ generatesCommands && !areCommandFilesUpToDate(projectRoot, toolId, options);
279
+ }
280
+ const needsUpdate = configured &&
281
+ (generatedByVersion === null ||
282
+ generatedByVersion !== currentVersion ||
283
+ commandsDrifted);
257
284
  return {
258
285
  toolId,
259
286
  toolName: tool.name,