@fission-ai/openspec 1.14.2 → 1.15.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 (66) hide show
  1. package/README.md +13 -9
  2. package/dist/cli/index.js +47 -0
  3. package/dist/commands/change.js +2 -1
  4. package/dist/commands/feedback.js +33 -20
  5. package/dist/commands/install-standalone.d.ts +27 -0
  6. package/dist/commands/install-standalone.js +150 -0
  7. package/dist/commands/schema.js +10 -3
  8. package/dist/commands/spec.js +2 -10
  9. package/dist/commands/validate.js +11 -4
  10. package/dist/commands/workflow/instructions.js +15 -4
  11. package/dist/commands/workflow/new-change.js +12 -4
  12. package/dist/commands/workflow/status.js +5 -2
  13. package/dist/commands/workflow/templates.js +5 -2
  14. package/dist/core/artifact-graph/outputs.js +204 -29
  15. package/dist/core/artifact-graph/resolver.d.ts +6 -0
  16. package/dist/core/artifact-graph/resolver.js +11 -0
  17. package/dist/core/available-tools.d.ts +2 -1
  18. package/dist/core/available-tools.js +8 -28
  19. package/dist/core/completion-tip.d.ts +23 -0
  20. package/dist/core/completion-tip.js +4 -4
  21. package/dist/core/completions/command-registry.js +15 -0
  22. package/dist/core/completions/completion-provider.js +1 -1
  23. package/dist/core/config-schema.d.ts +1 -0
  24. package/dist/core/config-schema.js +1 -0
  25. package/dist/core/file-view.d.ts +13 -0
  26. package/dist/core/file-view.js +14 -0
  27. package/dist/core/github-copilot/cloud-agent.d.ts +10 -2
  28. package/dist/core/github-copilot/cloud-agent.js +13 -9
  29. package/dist/core/global-config.d.ts +4 -0
  30. package/dist/core/global-config.js +5 -1
  31. package/dist/core/legacy-cleanup.d.ts +11 -1
  32. package/dist/core/legacy-cleanup.js +15 -18
  33. package/dist/core/migration.d.ts +27 -1
  34. package/dist/core/migration.js +77 -29
  35. package/dist/core/profile-sync-drift.d.ts +4 -3
  36. package/dist/core/profile-sync-drift.js +19 -16
  37. package/dist/core/shared/tool-detection.d.ts +12 -4
  38. package/dist/core/shared/tool-detection.js +29 -27
  39. package/dist/core/shared-skill-target.d.ts +12 -5
  40. package/dist/core/shared-skill-target.js +34 -23
  41. package/dist/core/specs-apply.js +23 -5
  42. package/dist/core/standalone-tip.d.ts +6 -0
  43. package/dist/core/standalone-tip.js +38 -0
  44. package/dist/core/templates/workflows/continue-change.js +18 -12
  45. package/dist/core/templates/workflows/explore.js +2 -2
  46. package/dist/core/templates/workflows/ff-change.js +8 -6
  47. package/dist/core/templates/workflows/propose.js +8 -6
  48. package/dist/core/templates/workflows/verify-change.js +4 -4
  49. package/dist/core/update-plan.d.ts +127 -0
  50. package/dist/core/update-plan.js +390 -0
  51. package/dist/core/update.d.ts +45 -1
  52. package/dist/core/update.js +279 -137
  53. package/dist/core/validation/validator.js +3 -3
  54. package/dist/core/version-check.d.ts +8 -1
  55. package/dist/core/version-check.js +48 -5
  56. package/dist/utils/executable-path.d.ts +5 -0
  57. package/dist/utils/executable-path.js +44 -0
  58. package/dist/utils/file-system.d.ts +1 -1
  59. package/dist/utils/file-system.js +1 -1
  60. package/dist/utils/interactive.d.ts +15 -0
  61. package/dist/utils/interactive.js +18 -0
  62. package/dist/utils/spec-discovery.d.ts +4 -4
  63. package/dist/utils/spec-discovery.js +7 -17
  64. package/dist/utils/spec-path-containment.d.ts +17 -0
  65. package/dist/utils/spec-path-containment.js +122 -0
  66. package/package.json +8 -4
@@ -242,10 +242,14 @@ function isLegacyCopilotCloudFile(relPath, content) {
242
242
  function isManagedCopilotCloudFile(relPath, content) {
243
243
  return isCurrentCopilotCloudFile(relPath, content) || isLegacyCopilotCloudFile(relPath, content);
244
244
  }
245
- async function reconcileCopilotCloudFile(fullPath, relPath) {
245
+ const DIRECT_CLOUD_FILE_OPS = {
246
+ writeFile: (filePath, content) => FileSystemUtils.writeFile(filePath, content),
247
+ removeFile: (filePath) => fs.unlink(filePath),
248
+ };
249
+ async function reconcileCopilotCloudFile(fullPath, relPath, ops) {
246
250
  const currentContent = COPILOT_CLOUD_FILE_CONTENTS[relPath];
247
251
  if (!(await FileSystemUtils.fileExists(fullPath))) {
248
- await FileSystemUtils.writeFile(fullPath, currentContent);
252
+ await ops.writeFile(fullPath, currentContent);
249
253
  return true;
250
254
  }
251
255
  const existingContent = await FileSystemUtils.readFile(fullPath);
@@ -255,7 +259,7 @@ async function reconcileCopilotCloudFile(fullPath, relPath) {
255
259
  if (!isLegacyCopilotCloudFile(relPath, existingContent)) {
256
260
  return false;
257
261
  }
258
- await FileSystemUtils.writeFile(fullPath, currentContent);
262
+ await ops.writeFile(fullPath, currentContent);
259
263
  return true;
260
264
  }
261
265
  async function assertCreatableFilePath(filePath) {
@@ -313,7 +317,7 @@ async function classifyCopilotAgentReconciliation(agentPath, alternateAgentPath)
313
317
  *
314
318
  * @returns Object indicating which files were written.
315
319
  */
316
- export async function writeCopilotCloudFiles(projectPath) {
320
+ export async function writeCopilotCloudFiles(projectPath, ops = DIRECT_CLOUD_FILE_OPS) {
317
321
  const setupStepsPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_CLOUD_FILES.setupSteps);
318
322
  const agentPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_CLOUD_FILES.agent);
319
323
  const alternateAgentPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_AGENT_ALTERNATE_FILE);
@@ -323,13 +327,13 @@ export async function writeCopilotCloudFiles(projectPath) {
323
327
  await assertMissingOrRegularFile(agentPath);
324
328
  await assertMissingOrRegularFile(alternateAgentPath);
325
329
  const agentReconciliation = await classifyCopilotAgentReconciliation(agentPath, alternateAgentPath);
326
- const setupStepsWritten = await reconcileCopilotCloudFile(setupStepsPath, COPILOT_CLOUD_FILES.setupSteps);
330
+ const setupStepsWritten = await reconcileCopilotCloudFile(setupStepsPath, COPILOT_CLOUD_FILES.setupSteps, ops);
327
331
  let agentWritten = false;
328
332
  if (agentReconciliation === 'reconcile') {
329
- agentWritten = await reconcileCopilotCloudFile(agentPath, COPILOT_CLOUD_FILES.agent);
333
+ agentWritten = await reconcileCopilotCloudFile(agentPath, COPILOT_CLOUD_FILES.agent, ops);
330
334
  }
331
335
  else if (agentReconciliation === 'remove-managed') {
332
- await fs.unlink(agentPath);
336
+ await ops.removeFile(agentPath);
333
337
  }
334
338
  return { setupStepsWritten, agentWritten };
335
339
  }
@@ -339,7 +343,7 @@ export async function writeCopilotCloudFiles(projectPath) {
339
343
  *
340
344
  * @returns Number of files removed.
341
345
  */
342
- export async function removeCopilotCloudFiles(projectPath) {
346
+ export async function removeCopilotCloudFiles(projectPath, ops = DIRECT_CLOUD_FILE_OPS) {
343
347
  let removed = 0;
344
348
  const managedPaths = Object.values(COPILOT_CLOUD_FILES).map((relPath) => ({
345
349
  relPath,
@@ -354,7 +358,7 @@ export async function removeCopilotCloudFiles(projectPath) {
354
358
  if (!isManagedCopilotCloudFile(relPath, content)) {
355
359
  continue;
356
360
  }
357
- await fs.unlink(fullPath);
361
+ await ops.removeFile(fullPath);
358
362
  removed++;
359
363
  }
360
364
  }
@@ -28,6 +28,8 @@ export interface GlobalConfig {
28
28
  telemetry?: TelemetryConfig;
29
29
  /** Whether the first-run shell-completions tip has been shown. */
30
30
  completionTipSeen?: boolean;
31
+ /** Whether the one-time note about the standalone install has been shown. */
32
+ standaloneTipSeen?: boolean;
31
33
  }
32
34
  /**
33
35
  * Gets the global configuration directory path following XDG Base Directory Specification.
@@ -94,4 +96,6 @@ export interface SaveGlobalConfigOptions {
94
96
  * existing file it cannot parse unless `replaceUnreadable` is set.
95
97
  */
96
98
  export declare function saveGlobalConfig(config: GlobalConfig, options?: SaveGlobalConfigOptions): void;
99
+ /** The bytes `saveGlobalConfig` writes, for a caller that only plans the write. */
100
+ export declare function serializeGlobalConfig(config: GlobalConfig): string;
97
101
  //# sourceMappingURL=global-config.d.ts.map
@@ -173,6 +173,10 @@ export function saveGlobalConfig(config, options = {}) {
173
173
  if (!fs.existsSync(configDir)) {
174
174
  fs.mkdirSync(configDir, { recursive: true });
175
175
  }
176
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
176
+ fs.writeFileSync(configPath, serializeGlobalConfig(config), 'utf-8');
177
+ }
178
+ /** The bytes `saveGlobalConfig` writes, for a caller that only plans the write. */
179
+ export function serializeGlobalConfig(config) {
180
+ return JSON.stringify(config, null, 2) + '\n';
177
181
  }
178
182
  //# sourceMappingURL=global-config.js.map
@@ -166,7 +166,17 @@ export interface CleanupResult {
166
166
  * @param detection - Detection result from detectLegacyArtifacts
167
167
  * @returns Cleanup result with summary of actions taken
168
168
  */
169
- export declare function cleanupLegacyArtifacts(projectPath: string, detection: LegacyDetectionResult): Promise<CleanupResult>;
169
+ /**
170
+ * Every change cleanup makes on disk. `update --dry-run` passes a recorder, so
171
+ * the plan comes from this same code rather than a description of it.
172
+ */
173
+ export interface LegacyCleanupOps {
174
+ writeFile(filePath: string, content: string): Promise<void>;
175
+ removeFile(filePath: string): Promise<void>;
176
+ /** Removes a directory already known to be empty. */
177
+ removeEmptyDirectory(dirPath: string): Promise<void>;
178
+ }
179
+ export declare function cleanupLegacyArtifacts(projectPath: string, detection: LegacyDetectionResult, ops?: LegacyCleanupOps): Promise<CleanupResult>;
170
180
  /**
171
181
  * Generates a cleanup summary message for display.
172
182
  *
@@ -323,14 +323,14 @@ function legacyCommandDirForFile(file) {
323
323
  * or records what is left in it as kept. Never recursive: whatever remains was
324
324
  * not written by OpenSpec. Returns true when the directory was removed.
325
325
  */
326
- async function settleLegacyCommandDir(projectPath, dirPath, result) {
326
+ async function settleLegacyCommandDir(projectPath, dirPath, result, ops = DIRECT_CLEANUP_OPS) {
327
327
  const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
328
328
  const remaining = await readLegacyCommandDir(fullPath, []);
329
329
  if (!remaining) {
330
330
  return false;
331
331
  }
332
332
  if (remaining.others.length === 0) {
333
- await fs.rmdir(fullPath);
333
+ await ops.removeEmptyDirectory(fullPath);
334
334
  result.deletedDirs.push(dirPath);
335
335
  return true;
336
336
  }
@@ -493,15 +493,12 @@ export function isOnlyOpenSpecContent(content) {
493
493
  export function removeMarkerBlock(content) {
494
494
  return removeMarkerBlockUtil(content, OPENSPEC_MARKERS.start, OPENSPEC_MARKERS.end);
495
495
  }
496
- /**
497
- * Cleans up legacy OpenSpec artifacts from a project.
498
- * Preserves openspec/project.md (shows migration hint instead of deleting).
499
- *
500
- * @param projectPath - The root path of the project
501
- * @param detection - Detection result from detectLegacyArtifacts
502
- * @returns Cleanup result with summary of actions taken
503
- */
504
- export async function cleanupLegacyArtifacts(projectPath, detection) {
496
+ const DIRECT_CLEANUP_OPS = {
497
+ writeFile: (filePath, content) => FileSystemUtils.writeFile(filePath, content),
498
+ removeFile: (filePath) => fs.unlink(filePath),
499
+ removeEmptyDirectory: (dirPath) => fs.rmdir(dirPath),
500
+ };
501
+ export async function cleanupLegacyArtifacts(projectPath, detection, ops = DIRECT_CLEANUP_OPS) {
505
502
  const result = {
506
503
  deletedFiles: [],
507
504
  deletedFileReplacementLabels: {},
@@ -519,7 +516,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
519
516
  const content = await FileSystemUtils.readFile(filePath);
520
517
  const newContent = removeMarkerBlock(content);
521
518
  // Always write the file, even if empty - never delete user config files
522
- await FileSystemUtils.writeFile(filePath, newContent);
519
+ await ops.writeFile(filePath, newContent);
523
520
  result.modifiedFiles.push(fileName);
524
521
  }
525
522
  catch (error) {
@@ -545,10 +542,10 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
545
542
  if (!(await isGeneratedLegacyCommand(filePath))) {
546
543
  continue;
547
544
  }
548
- await fs.unlink(filePath);
545
+ await ops.removeFile(filePath);
549
546
  deleted.push(name);
550
547
  }
551
- if (!(await settleLegacyCommandDir(projectPath, dirPath, result))) {
548
+ if (!(await settleLegacyCommandDir(projectPath, dirPath, result, ops))) {
552
549
  result.deletedFiles.push(...deleted.map((name) => `${dirPath}/${name}`));
553
550
  }
554
551
  }
@@ -570,7 +567,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
570
567
  continue;
571
568
  }
572
569
  }
573
- await fs.unlink(fullPath);
570
+ await ops.removeFile(fullPath);
574
571
  result.deletedFiles.push(filePath);
575
572
  }
576
573
  catch (error) {
@@ -581,7 +578,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
581
578
  // file by file above; record what was left in it.
582
579
  for (const dirPath of partlyCleanedDirs) {
583
580
  try {
584
- await settleLegacyCommandDir(projectPath, dirPath, result);
581
+ await settleLegacyCommandDir(projectPath, dirPath, result, ops);
585
582
  }
586
583
  catch (error) {
587
584
  result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
@@ -595,7 +592,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
595
592
  continue;
596
593
  }
597
594
  try {
598
- await fs.unlink(filePath);
595
+ await ops.removeFile(filePath);
599
596
  result.deletedFiles.push(filePath);
600
597
  const promptMatch = globalPromptMatchesByPath.get(filePath);
601
598
  if (promptMatch?.replacementLabel) {
@@ -611,7 +608,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
611
608
  const agentsPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'AGENTS.md');
612
609
  if (await FileSystemUtils.fileExists(agentsPath)) {
613
610
  try {
614
- await fs.unlink(agentsPath);
611
+ await ops.removeFile(agentsPath);
615
612
  result.deletedFiles.push('openspec/AGENTS.md');
616
613
  }
617
614
  catch (error) {
@@ -5,6 +5,8 @@
5
5
  * Called by both init and update commands before profile resolution.
6
6
  */
7
7
  import { type AIToolOption } from './config.js';
8
+ import { type Delivery, type GlobalConfig, type Profile } from './global-config.js';
9
+ import { type FileView } from './file-view.js';
8
10
  export interface LegacyToolRoot {
9
11
  /** Former tool root, e.g. '.kimi' */
10
12
  root: string;
@@ -41,11 +43,27 @@ export interface LegacyToolMigration {
41
43
  /** Whether this move needs the user's consent first */
42
44
  needsConsent: boolean;
43
45
  }
46
+ /** One file a legacy-root migration would move or drop. */
47
+ export interface LegacyMigrationFileOp {
48
+ toolId: string;
49
+ source: string;
50
+ destination: string;
51
+ disposition: 'move' | 'drop';
52
+ /** Whether an interactive run asks before this move */
53
+ needsConsent: boolean;
54
+ }
44
55
  /**
45
56
  * Reports the OpenSpec content sitting under each tool's legacy root, without
46
57
  * moving anything. Callers use this to ask before a move that needs consent.
47
58
  */
48
59
  export declare function findLegacyToolMigrations(projectPath: string, timing?: 'before-generation' | 'after-generation'): LegacyToolMigration[];
60
+ /**
61
+ * Lists, file by file, what `migrateLegacyToolDirs` would move or drop, without
62
+ * touching anything. Consent-gated roots are included: a non-interactive run
63
+ * moves them without asking. `view` decides what already sits at each
64
+ * destination, so a dry run can plan against files it has not written.
65
+ */
66
+ export declare function planLegacyToolMigrations(projectPath: string, timing: 'before-generation' | 'after-generation', toolIds: string[] | undefined, view: FileView): LegacyMigrationFileOp[];
49
67
  /**
50
68
  * Moves OpenSpec-managed skill directories (openspec-*) and command files
51
69
  * (opsx-*) from a tool's legacy root to its current one. When the destination
@@ -81,7 +99,7 @@ export declare function legacyMigrationNotice(migration: LegacyToolMigration): s
81
99
  * Scans installed workflow files across all detected tools and returns
82
100
  * the union of installed workflow IDs.
83
101
  */
84
- export declare function scanInstalledWorkflows(projectPath: string, tools: AIToolOption[]): string[];
102
+ export declare function scanInstalledWorkflows(projectPath: string, tools: AIToolOption[], view?: FileView): string[];
85
103
  /**
86
104
  * Performs one-time migration if the global config does not yet have a profile field.
87
105
  * Called by both init and update before profile resolution.
@@ -91,5 +109,13 @@ export declare function scanInstalledWorkflows(projectPath: string, tools: AIToo
91
109
  * - If no profile field exists and no workflows are installed: no-op (defaults apply).
92
110
  * - If profile field already exists: no-op.
93
111
  */
112
+ export declare function computeProfileMigration(projectPath: string, tools: AIToolOption[], view?: FileView): ProfileMigration | null;
113
+ export type ProfileMigration = {
114
+ profile: Profile;
115
+ workflows: string[];
116
+ delivery?: Delivery;
117
+ };
118
+ /** The global config the one-time profile migration saves. */
119
+ export declare function applyProfileMigration(migration: ProfileMigration): GlobalConfig;
94
120
  export declare function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): void;
95
121
  //# sourceMappingURL=migration.d.ts.map
@@ -14,6 +14,7 @@ import { ALL_WORKFLOWS } from './profiles.js';
14
14
  import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js';
15
15
  import { FileSystemUtils } from '../utils/file-system.js';
16
16
  import { isSharedSkillTargetActive } from './shared-skill-target.js';
17
+ import { DISK_VIEW } from './file-view.js';
17
18
  import { isLegacyCodexSkillEquivalentToCurrent } from './shared/skill-content-equivalence.js';
18
19
  import path from 'path';
19
20
  import * as fs from 'fs';
@@ -41,14 +42,14 @@ export const LEGACY_TOOL_ROOTS = {
41
42
  // reported and kept rather than overwritten.
42
43
  antigravity: [{ root: '.agent', needsConsent: false, timing: 'after-generation' }],
43
44
  };
44
- function classifyManagedFile(source, destination) {
45
+ function classifyManagedFile(source, destination, view = DISK_VIEW) {
45
46
  if (isSamePath(source, destination))
46
47
  return 'skip';
47
- if (!fs.existsSync(destination))
48
+ if (!view.exists(destination))
48
49
  return 'move';
49
50
  try {
50
51
  const sourceContent = fs.readFileSync(source, 'utf-8');
51
- const destinationContent = fs.readFileSync(destination, 'utf-8');
52
+ const destinationContent = view.read(destination);
52
53
  const equivalentGeneratedSkills = path.basename(source) === 'SKILL.md' &&
53
54
  path.basename(destination) === 'SKILL.md' &&
54
55
  isLegacyCodexSkillEquivalentToCurrent(sourceContent, destinationContent);
@@ -85,6 +86,17 @@ function legacyCommandPath(commandPath, currentRoot, legacyRoot) {
85
86
  export function findLegacyToolMigrations(projectPath, timing = 'before-generation') {
86
87
  return collectLegacyToolMigrations(projectPath, false, undefined, timing);
87
88
  }
89
+ /**
90
+ * Lists, file by file, what `migrateLegacyToolDirs` would move or drop, without
91
+ * touching anything. Consent-gated roots are included: a non-interactive run
92
+ * moves them without asking. `view` decides what already sits at each
93
+ * destination, so a dry run can plan against files it has not written.
94
+ */
95
+ export function planLegacyToolMigrations(projectPath, timing, toolIds, view) {
96
+ const ops = [];
97
+ collectLegacyToolMigrations(projectPath, false, toolIds, timing, { view, ops });
98
+ return ops;
99
+ }
88
100
  /**
89
101
  * Moves OpenSpec-managed skill directories (openspec-*) and command files
90
102
  * (opsx-*) from a tool's legacy root to its current one. When the destination
@@ -99,7 +111,7 @@ export function findLegacyToolMigrations(projectPath, timing = 'before-generatio
99
111
  export function migrateLegacyToolDirs(projectPath, toolIds, timing = 'before-generation') {
100
112
  return collectLegacyToolMigrations(projectPath, true, toolIds, timing);
101
113
  }
102
- function collectLegacyToolMigrations(projectPath, apply, toolIds, timing = 'before-generation') {
114
+ function collectLegacyToolMigrations(projectPath, apply, toolIds, timing = 'before-generation', plan) {
103
115
  const migrations = [];
104
116
  for (const tool of AI_TOOLS) {
105
117
  if (!tool.skillsDir)
@@ -126,8 +138,11 @@ function collectLegacyToolMigrations(projectPath, apply, toolIds, timing = 'befo
126
138
  console.warn(`Skipping legacy ${legacy.root}/ migration because the directory resolves outside this project.`);
127
139
  continue;
128
140
  }
129
- const skills = migrateSkillDirs(projectPath, tool.skillsDir, legacy.root, apply, legacyTiming === 'after-generation');
130
- const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply, legacyTiming === 'after-generation');
141
+ const sink = plan
142
+ ? { ...plan, toolId: tool.value, needsConsent: legacy.needsConsent }
143
+ : undefined;
144
+ const skills = migrateSkillDirs(projectPath, tool.skillsDir, legacy.root, apply, legacyTiming === 'after-generation', sink);
145
+ const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply, legacyTiming === 'after-generation', sink);
131
146
  if (apply) {
132
147
  removeDirIfEmpty(path.join(legacyRootPath, 'skills'));
133
148
  removeDirIfEmpty(path.join(legacyRootPath, 'workflows'));
@@ -152,7 +167,8 @@ function collectLegacyToolMigrations(projectPath, apply, toolIds, timing = 'befo
152
167
  }
153
168
  return migrations;
154
169
  }
155
- function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDestination = false) {
170
+ function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDestination = false, sink) {
171
+ const view = sink?.view ?? DISK_VIEW;
156
172
  const legacySkillsDir = path.join(projectPath, legacyRoot, 'skills');
157
173
  if (!fs.existsSync(legacySkillsDir))
158
174
  return { moved: 0, kept: 0 };
@@ -167,13 +183,13 @@ function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDe
167
183
  continue;
168
184
  const destination = path.join(currentSkillsDir, dirName);
169
185
  const destinationSkill = path.join(destination, 'SKILL.md');
170
- if (requireDestination && !fs.existsSync(destinationSkill))
186
+ if (requireDestination && !view.exists(destinationSkill))
171
187
  continue;
172
188
  if (!areProjectArtifacts(projectPath, sourceSkill, destinationSkill)) {
173
189
  console.warn(`Skipping legacy ${legacyRoot}/skills/${dirName} migration because it resolves outside this project.`);
174
190
  continue;
175
191
  }
176
- const disposition = classifyManagedFile(sourceSkill, destinationSkill);
192
+ const disposition = classifyManagedFile(sourceSkill, destinationSkill, view);
177
193
  if (disposition === 'skip')
178
194
  continue;
179
195
  if (disposition === 'keep') {
@@ -181,6 +197,13 @@ function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDe
181
197
  continue;
182
198
  }
183
199
  if (!apply) {
200
+ sink?.ops.push({
201
+ toolId: sink.toolId,
202
+ source: sourceSkill,
203
+ destination: destinationSkill,
204
+ disposition,
205
+ needsConsent: sink.needsConsent,
206
+ });
184
207
  moved++;
185
208
  continue;
186
209
  }
@@ -207,7 +230,8 @@ function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDe
207
230
  }
208
231
  return { moved, kept };
209
232
  }
210
- function migrateCommandFiles(projectPath, tool, legacyRoot, apply, requireDestination = false) {
233
+ function migrateCommandFiles(projectPath, tool, legacyRoot, apply, requireDestination = false, sink) {
234
+ const view = sink?.view ?? DISK_VIEW;
211
235
  const adapter = CommandAdapterRegistry.get(tool.value);
212
236
  if (!adapter || !tool.skillsDir)
213
237
  return { moved: 0, kept: 0 };
@@ -226,13 +250,13 @@ function migrateCommandFiles(projectPath, tool, legacyRoot, apply, requireDestin
226
250
  // No replacement means this command is not one OpenSpec installs now — a
227
251
  // skills-only delivery or a deselected workflow — so relocating the legacy
228
252
  // file would resurrect it under the current root.
229
- if (requireDestination && !fs.existsSync(destination))
253
+ if (requireDestination && !view.exists(destination))
230
254
  continue;
231
255
  if (!areProjectArtifacts(projectPath, source, destination)) {
232
256
  console.warn(`Skipping legacy ${legacyPath} migration because it resolves outside this project.`);
233
257
  continue;
234
258
  }
235
- const disposition = classifyManagedFile(source, destination);
259
+ const disposition = classifyManagedFile(source, destination, view);
236
260
  if (disposition === 'skip')
237
261
  continue;
238
262
  if (disposition === 'keep') {
@@ -240,6 +264,13 @@ function migrateCommandFiles(projectPath, tool, legacyRoot, apply, requireDestin
240
264
  continue;
241
265
  }
242
266
  if (!apply) {
267
+ sink?.ops.push({
268
+ toolId: sink.toolId,
269
+ source,
270
+ destination,
271
+ disposition,
272
+ needsConsent: sink.needsConsent,
273
+ });
243
274
  moved++;
244
275
  continue;
245
276
  }
@@ -344,7 +375,7 @@ function removeDirIfEmpty(dirPath) {
344
375
  // Missing or non-empty directory — nothing to do
345
376
  }
346
377
  }
347
- function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots = false) {
378
+ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots = false, view = DISK_VIEW) {
348
379
  const installed = new Set();
349
380
  let hasSkills = false;
350
381
  let hasCommands = false;
@@ -355,7 +386,7 @@ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots =
355
386
  if (tool.globalSkillsDir) {
356
387
  skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
357
388
  }
358
- else if (isSharedSkillTargetActive(projectPath, tool.value)) {
389
+ else if (isSharedSkillTargetActive(projectPath, tool.value, view)) {
359
390
  skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
360
391
  if (includeLegacyRoots) {
361
392
  skillsDirs.push(...(tool.legacySkillsDirs ?? []).map((root) => path.join(projectPath, root, 'skills')));
@@ -365,7 +396,7 @@ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots =
365
396
  for (const workflowId of ALL_WORKFLOWS) {
366
397
  const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId];
367
398
  const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md');
368
- if (fs.existsSync(skillFile)) {
399
+ if (view.exists(skillFile)) {
369
400
  installed.add(workflowId);
370
401
  hasSkills = true;
371
402
  }
@@ -395,7 +426,7 @@ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots =
395
426
  const fullPath = path.isAbsolute(candidate)
396
427
  ? candidate
397
428
  : path.join(projectPath, candidate);
398
- if (fs.existsSync(fullPath)) {
429
+ if (view.exists(fullPath)) {
399
430
  installed.add(workflowId);
400
431
  hasCommands = true;
401
432
  break;
@@ -413,8 +444,8 @@ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots =
413
444
  * Scans installed workflow files across all detected tools and returns
414
445
  * the union of installed workflow IDs.
415
446
  */
416
- export function scanInstalledWorkflows(projectPath, tools) {
417
- return scanInstalledWorkflowArtifacts(projectPath, tools).workflows;
447
+ export function scanInstalledWorkflows(projectPath, tools, view = DISK_VIEW) {
448
+ return scanInstalledWorkflowArtifacts(projectPath, tools, false, view).workflows;
418
449
  }
419
450
  function inferDelivery(artifacts) {
420
451
  if (artifacts.hasSkills && artifacts.hasCommands) {
@@ -434,13 +465,12 @@ function inferDelivery(artifacts) {
434
465
  * - If no profile field exists and no workflows are installed: no-op (defaults apply).
435
466
  * - If profile field already exists: no-op.
436
467
  */
437
- export function migrateIfNeeded(projectPath, tools) {
468
+ export function computeProfileMigration(projectPath, tools, view = DISK_VIEW) {
438
469
  // A config that cannot be parsed, or is not a JSON object, is never saved
439
470
  // over; skip migration rather than fail init or update on it.
440
471
  if (isGlobalConfigUnreadable()) {
441
- return;
472
+ return null;
442
473
  }
443
- const config = getGlobalConfig();
444
474
  // Check raw config file for profile field presence
445
475
  const configPath = getGlobalConfigPath();
446
476
  let rawConfig = {};
@@ -450,25 +480,43 @@ export function migrateIfNeeded(projectPath, tools) {
450
480
  }
451
481
  }
452
482
  catch {
453
- return; // Can't read config, skip migration
483
+ return null; // Can't read config, skip migration
454
484
  }
455
485
  // If profile is already explicitly set, no migration needed
456
486
  if (rawConfig.profile !== undefined) {
457
- return;
487
+ return null;
458
488
  }
459
489
  // Scan for installed workflows
460
- const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools, true);
490
+ const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools, true, view);
461
491
  const installedWorkflows = artifacts.workflows;
462
492
  if (installedWorkflows.length === 0) {
463
493
  // No workflows installed, new user — defaults will apply
464
- return;
494
+ return null;
465
495
  }
496
+ return {
497
+ profile: 'custom',
498
+ workflows: installedWorkflows,
499
+ ...(rawConfig.delivery === undefined ? { delivery: inferDelivery(artifacts) } : {}),
500
+ };
501
+ }
502
+ /** The global config the one-time profile migration saves. */
503
+ export function applyProfileMigration(migration) {
504
+ const config = getGlobalConfig();
466
505
  // Migrate: set profile to custom with detected workflows
467
- config.profile = 'custom';
468
- config.workflows = installedWorkflows;
469
- if (rawConfig.delivery === undefined) {
470
- config.delivery = inferDelivery(artifacts);
506
+ config.profile = migration.profile;
507
+ config.workflows = migration.workflows;
508
+ if (migration.delivery !== undefined) {
509
+ config.delivery = migration.delivery;
510
+ }
511
+ return config;
512
+ }
513
+ export function migrateIfNeeded(projectPath, tools) {
514
+ const migration = computeProfileMigration(projectPath, tools);
515
+ if (!migration) {
516
+ return;
471
517
  }
518
+ const config = applyProfileMigration(migration);
519
+ const installedWorkflows = migration.workflows;
472
520
  saveGlobalConfig(config);
473
521
  console.log(`Migrated: custom profile with ${installedWorkflows.length} workflows`);
474
522
  // Each detected tool resolves to a propose reference for its surface: the
@@ -1,5 +1,6 @@
1
1
  import type { Delivery } from './global-config.js';
2
2
  import { ALL_WORKFLOWS } from './profiles.js';
3
+ import { type FileView } from './file-view.js';
3
4
  type WorkflowId = (typeof ALL_WORKFLOWS)[number];
4
5
  /**
5
6
  * Maps workflow IDs to their skill directory names.
@@ -8,7 +9,7 @@ export declare const WORKFLOW_TO_SKILL_DIR: Record<WorkflowId, string>;
8
9
  /**
9
10
  * Returns tools that are configured via either skills or commands.
10
11
  */
11
- export declare function getConfiguredToolsForProfileSync(projectPath: string): string[];
12
+ export declare function getConfiguredToolsForProfileSync(projectPath: string, view?: FileView): string[];
12
13
  /**
13
14
  * Detects if a single tool has profile/delivery drift against the desired state.
14
15
  *
@@ -17,11 +18,11 @@ export declare function getConfiguredToolsForProfileSync(projectPath: string): s
17
18
  * - artifacts that should not exist for the selected delivery mode
18
19
  * - artifacts for workflows that were deselected from the current profile
19
20
  */
20
- export declare function hasToolProfileOrDeliveryDrift(projectPath: string, toolId: string, desiredWorkflows: readonly string[], delivery: Delivery): boolean;
21
+ export declare function hasToolProfileOrDeliveryDrift(projectPath: string, toolId: string, desiredWorkflows: readonly string[], delivery: Delivery, view?: FileView): boolean;
21
22
  /**
22
23
  * Returns configured tools that currently need a profile/delivery sync.
23
24
  */
24
- export declare function getToolsNeedingProfileSync(projectPath: string, desiredWorkflows: readonly string[], delivery: Delivery, configuredTools?: readonly string[]): string[];
25
+ export declare function getToolsNeedingProfileSync(projectPath: string, desiredWorkflows: readonly string[], delivery: Delivery, configuredTools?: readonly string[], view?: FileView): string[];
25
26
  /**
26
27
  * Detects whether the current project has any profile/delivery drift.
27
28
  */