@fission-ai/openspec 1.10.0 → 1.11.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 (40) hide show
  1. package/dist/cli/index.js +11 -2
  2. package/dist/commands/change.d.ts +24 -0
  3. package/dist/commands/change.js +223 -0
  4. package/dist/commands/schema.d.ts +5 -0
  5. package/dist/commands/schema.js +201 -35
  6. package/dist/commands/show.js +1 -1
  7. package/dist/commands/workflow/index.d.ts +1 -1
  8. package/dist/commands/workflow/index.js +1 -1
  9. package/dist/commands/workflow/status.d.ts +2 -0
  10. package/dist/commands/workflow/status.js +76 -12
  11. package/dist/core/artifact-graph/resolver.js +15 -9
  12. package/dist/core/available-tools.js +16 -1
  13. package/dist/core/command-generation/adapters/antigravity.d.ts +1 -1
  14. package/dist/core/command-generation/adapters/antigravity.js +2 -2
  15. package/dist/core/completions/command-registry.js +15 -0
  16. package/dist/core/completions/generators/fish-generator.d.ts +12 -0
  17. package/dist/core/completions/generators/fish-generator.js +79 -47
  18. package/dist/core/completions/templates/fish-templates.d.ts +1 -1
  19. package/dist/core/completions/templates/fish-templates.js +107 -10
  20. package/dist/core/completions/types.d.ts +4 -0
  21. package/dist/core/config.js +6 -1
  22. package/dist/core/init.js +50 -31
  23. package/dist/core/legacy-cleanup.js +6 -0
  24. package/dist/core/migration.js +42 -12
  25. package/dist/core/parsers/requirement-blocks.d.ts +1 -0
  26. package/dist/core/parsers/requirement-blocks.js +2 -0
  27. package/dist/core/shared/tool-detection.js +9 -3
  28. package/dist/core/shared-skill-target.d.ts +13 -0
  29. package/dist/core/shared-skill-target.js +43 -1
  30. package/dist/core/specs-apply.js +16 -8
  31. package/dist/core/templates/workflows/explore.js +79 -73
  32. package/dist/core/update.js +24 -22
  33. package/dist/core/validation/constants.d.ts +3 -0
  34. package/dist/core/validation/constants.js +12 -0
  35. package/dist/core/validation/purpose-placeholder.d.ts +45 -0
  36. package/dist/core/validation/purpose-placeholder.js +117 -0
  37. package/dist/core/validation/validator.js +16 -1
  38. package/dist/utils/requirement-diff.d.ts +42 -0
  39. package/dist/utils/requirement-diff.js +61 -0
  40. package/package.json +2 -1
@@ -19,6 +19,10 @@ export interface FlagDefinition {
19
19
  * Whether the flag takes an argument value
20
20
  */
21
21
  takesValue?: boolean;
22
+ /**
23
+ * Completion type for the flag value.
24
+ */
25
+ completionType?: PositionalType;
22
26
  /**
23
27
  * Possible values for the flag (for completion suggestions)
24
28
  */
@@ -19,7 +19,12 @@ export const OPENSPEC_MARKERS = {
19
19
  };
20
20
  export const AI_TOOLS = [
21
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 },
22
+ // Antigravity moved workspace skills and workflows from `.agent` to the
23
+ // shared `.agents` root in v1.20.5. Detection keys off `.agent` and
24
+ // `.agents/workflows` rather than the bare `.agents` root: that root is
25
+ // shared with Codex, Zed, and the vendor-neutral target, so its presence
26
+ // alone says nothing about Antigravity.
27
+ { name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agents', legacySkillsDirs: ['.agent'], detectionPaths: ['.agent', '.agents/workflows'], requiresIdeRestart: true },
23
28
  { name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment' },
24
29
  { name: 'Bob Shell', value: 'bob', available: true, successLabel: 'Bob Shell', skillsDir: '.bob' },
25
30
  { name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' },
package/dist/core/init.js CHANGED
@@ -23,7 +23,7 @@ import { getToolsWithSkillsDir, getToolStates, getSkillTemplates, getCommandCont
23
23
  import { getGlobalConfig } from './global-config.js';
24
24
  import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js';
25
25
  import { getAvailableTools } from './available-tools.js';
26
- import { writeSharedSkillTarget } from './shared-skill-target.js';
26
+ import { resolveSharedSkillWriters, sharedSkillRootOwner, writeSharedSkillTarget, } from './shared-skill-target.js';
27
27
  import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptInPlaceNotice, hasMovableContent, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js';
28
28
  import { resolveCommandSurfaceCapability, resolveCommandInvocation, shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js';
29
29
  import { writeCopilotCloudFiles, readCopilotCloudOptIn, hasExistingManagedCloudFiles, persistCopilotCloudOptIn, removeCopilotCloudFiles, findUnmanagedCloudFiles, listManagedCloudFiles, } from './github-copilot/cloud-agent.js';
@@ -526,34 +526,8 @@ export class InitCommand {
526
526
  return deduped;
527
527
  }
528
528
  validateTools(toolIds, toolStates, projectPath) {
529
- const validatedTools = [];
530
- const sharedAgentsTargets = ['codex', 'zed', 'agents'];
531
- const selectedSharedTargets = sharedAgentsTargets.filter((toolId) => toolIds.includes(toolId));
532
- // A Codex-rendered tree already serves Zed. Keep it when Zed is added later
533
- // so Codex users do not lose the `$openspec-*` references they require.
534
- const preserveConfiguredCodex = selectedSharedTargets.includes('zed') &&
535
- toolStates.get('codex')?.configured;
536
- const sharedTargetCandidates = preserveConfiguredCodex
537
- ? [...new Set([...selectedSharedTargets, 'codex'])]
538
- : selectedSharedTargets;
539
- const sharedTargetOwner = sharedTargetCandidates.includes('codex')
540
- ? 'codex'
541
- : selectedSharedTargets.includes('zed')
542
- ? 'zed'
543
- : selectedSharedTargets[0];
544
- const firstSharedIndex = toolIds.findIndex((id) => sharedAgentsTargets.includes(id));
545
- const reconciledToolIds = sharedTargetCandidates.length > 1
546
- ? toolIds.flatMap((toolId, index) => {
547
- if (!sharedAgentsTargets.includes(toolId))
548
- return [toolId];
549
- return index === firstSharedIndex && sharedTargetOwner ? [sharedTargetOwner] : [];
550
- })
551
- : toolIds;
552
- if (reconciledToolIds.length !== toolIds.length ||
553
- reconciledToolIds.some((toolId, index) => toolId !== toolIds[index])) {
554
- console.log(chalk.dim(`Codex, Zed, and agents share .agents/skills; writing one tree for ${sharedTargetOwner}.`));
555
- }
556
- for (const toolId of reconciledToolIds) {
529
+ const selectedTools = [];
530
+ for (const toolId of toolIds) {
557
531
  const tool = AI_TOOLS.find((t) => t.value === toolId);
558
532
  if (!tool) {
559
533
  const validToolIds = getToolsWithSkillsDir();
@@ -563,6 +537,48 @@ export class InitCommand {
563
537
  const validToolsWithSkills = getToolsWithSkillsDir();
564
538
  throw new Error(`Tool '${toolId}' does not support skill generation.\nTools with skill generation support:\n ${validToolsWithSkills.join('\n ')}`);
565
539
  }
540
+ selectedTools.push(tool);
541
+ }
542
+ // A selected tool may share its physical skills root with an already
543
+ // configured owner. Include that owner in the refresh without dropping the
544
+ // selected tool: it may still have an independent command surface.
545
+ const generationTools = [...selectedTools];
546
+ const delivery = getGlobalConfig().delivery ?? 'both';
547
+ for (const selected of selectedTools) {
548
+ if (!selected.skillsDir)
549
+ continue;
550
+ const selectedOwner = selected.value === 'codex' ||
551
+ !shouldGenerateSkillsForTool(selected.value, delivery)
552
+ ? undefined
553
+ : sharedSkillRootOwner(projectPath, selected.value);
554
+ for (const candidate of AI_TOOLS) {
555
+ if (candidate.skillsDir === selected.skillsDir &&
556
+ toolStates.get(candidate.value)?.configured &&
557
+ candidate.value === selectedOwner &&
558
+ !generationTools.includes(candidate)) {
559
+ generationTools.push(candidate);
560
+ }
561
+ }
562
+ }
563
+ const skillWriters = resolveSharedSkillWriters(projectPath, generationTools);
564
+ const sharedRoots = new Map();
565
+ for (const tool of generationTools) {
566
+ if (!tool.skillsDir)
567
+ continue;
568
+ const group = sharedRoots.get(tool.skillsDir) ?? [];
569
+ group.push(tool);
570
+ sharedRoots.set(tool.skillsDir, group);
571
+ }
572
+ for (const [root, group] of sharedRoots) {
573
+ if (group.length < 2)
574
+ continue;
575
+ const owner = group.find((tool) => skillWriters.has(tool.value));
576
+ console.log(chalk.dim(`${group.map((tool) => tool.name).join(', ')} share ${root}/skills; writing one tree for ${owner?.value}.`));
577
+ }
578
+ const validatedTools = [];
579
+ for (const tool of generationTools) {
580
+ if (!toolSupportsSkills(tool))
581
+ continue;
566
582
  const preState = toolStates.get(tool.value);
567
583
  const skillsPath = resolveToolSkillsDir(projectPath, tool);
568
584
  const isGlobalSkillTarget = hasGlobalSkillTarget(tool);
@@ -575,6 +591,7 @@ export class InitCommand {
575
591
  isGlobalSkillTarget,
576
592
  wasConfigured: preState?.configured ?? false,
577
593
  requiresIdeRestart: tool.requiresIdeRestart,
594
+ writesSkills: !tool.skillsDir || skillWriters.has(tool.value),
578
595
  });
579
596
  }
580
597
  return validatedTools;
@@ -648,7 +665,7 @@ export class InitCommand {
648
665
  const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery);
649
666
  const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);
650
667
  // Generate skill files if the selected delivery and tool capability allow skills
651
- if (shouldGenerateSkills) {
668
+ if (shouldGenerateSkills && tool.writesSkills) {
652
669
  // Create skill directories and SKILL.md files
653
670
  for (const { template, dirName } of skillTemplates) {
654
671
  const skillDir = path.join(tool.skillsPath, dirName);
@@ -662,7 +679,9 @@ export class InitCommand {
662
679
  }
663
680
  writeSharedSkillTarget(projectPath, tool.value);
664
681
  }
665
- if (shouldRemoveSkillsForTool(tool.value, delivery) && !tool.isGlobalSkillTarget) {
682
+ if (shouldRemoveSkillsForTool(tool.value, delivery) &&
683
+ tool.writesSkills &&
684
+ !tool.isGlobalSkillTarget) {
666
685
  removedSkillCount += await this.removeSkillDirs(tool.skillsRoot, tool.skillsPath);
667
686
  // Retain an explicit selection even when this delivery mode produces
668
687
  // no skills, so a divergent legacy sibling cannot reclaim ownership.
@@ -51,6 +51,12 @@ export const LEGACY_SLASH_COMMAND_PATHS = {
51
51
  'factory': { type: 'files', pattern: '.factory/commands/openspec-*.md' },
52
52
  'opencode': { type: 'files', pattern: ['.opencode/command/opsx-*.md', '.opencode/command/openspec-*.md'] },
53
53
  'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' },
54
+ // Scoped to the pre-opsx filenames under Antigravity's former `.agent` root.
55
+ // The current `.agents/workflows/opsx-*.md` files postdate that rename, and
56
+ // the `.agent` copies of them are relocated by LEGACY_TOOL_ROOTS, which
57
+ // preserves a customized file instead of deleting it. These patterns are
58
+ // matched in every project, so a shared root like `.agents` is not listed:
59
+ // OpenSpec never wrote `openspec-*` files there, and a user might have.
54
60
  'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' },
55
61
  'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' },
56
62
  'qwen': { type: 'files', pattern: ['.qwen/commands/opsx-*.toml', '.qwen/commands/openspec-*.toml'] },
@@ -34,6 +34,12 @@ export const LEGACY_TOOL_ROOTS = {
34
34
  // Codex now reads the canonical shared .agents root. Generate the current
35
35
  // replacement first so a divergent legacy file is preserved, not overwritten.
36
36
  codex: [{ root: '.codex', needsConsent: false, timing: 'after-generation' }],
37
+ // Antigravity v1.20.5 moved workspace skills and workflows to `.agents` and
38
+ // reads the old `.agent` root only as a fallback, so leaving a copy there
39
+ // just gives the agent two of everything. Same after-generation timing as
40
+ // Codex: the replacement is written first, so a divergent legacy file is
41
+ // reported and kept rather than overwritten.
42
+ antigravity: [{ root: '.agent', needsConsent: false, timing: 'after-generation' }],
37
43
  };
38
44
  function classifyManagedFile(source, destination) {
39
45
  if (isSamePath(source, destination))
@@ -121,7 +127,7 @@ function collectLegacyToolMigrations(projectPath, apply, toolIds, timing = 'befo
121
127
  continue;
122
128
  }
123
129
  const skills = migrateSkillDirs(projectPath, tool.skillsDir, legacy.root, apply, legacyTiming === 'after-generation');
124
- const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply);
130
+ const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply, legacyTiming === 'after-generation');
125
131
  if (apply) {
126
132
  removeDirIfEmpty(path.join(legacyRootPath, 'skills'));
127
133
  removeDirIfEmpty(path.join(legacyRootPath, 'workflows'));
@@ -201,7 +207,7 @@ function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDe
201
207
  }
202
208
  return { moved, kept };
203
209
  }
204
- function migrateCommandFiles(projectPath, tool, legacyRoot, apply) {
210
+ function migrateCommandFiles(projectPath, tool, legacyRoot, apply, requireDestination = false) {
205
211
  const adapter = CommandAdapterRegistry.get(tool.value);
206
212
  if (!adapter || !tool.skillsDir)
207
213
  return { moved: 0, kept: 0 };
@@ -212,10 +218,16 @@ function migrateCommandFiles(projectPath, tool, legacyRoot, apply) {
212
218
  const legacyPath = legacyCommandPath(currentPath, tool.skillsDir, legacyRoot);
213
219
  if (!legacyPath)
214
220
  continue;
215
- const source = path.join(projectPath, legacyPath);
221
+ const source = FileSystemUtils.resolveProjectArtifactPath(projectPath, legacyPath);
216
222
  if (!fs.existsSync(source))
217
223
  continue;
218
- const destination = path.join(projectPath, currentPath);
224
+ const destination = FileSystemUtils.resolveProjectArtifactPath(projectPath, currentPath.split(/[\\/]/).join(path.sep));
225
+ // An after-generation move runs once the tool has written its replacement.
226
+ // No replacement means this command is not one OpenSpec installs now — a
227
+ // skills-only delivery or a deselected workflow — so relocating the legacy
228
+ // file would resurrect it under the current root.
229
+ if (requireDestination && !fs.existsSync(destination))
230
+ continue;
219
231
  if (!areProjectArtifacts(projectPath, source, destination)) {
220
232
  console.warn(`Skipping legacy ${legacyPath} migration because it resolves outside this project.`);
221
233
  continue;
@@ -332,7 +344,7 @@ function removeDirIfEmpty(dirPath) {
332
344
  // Missing or non-empty directory — nothing to do
333
345
  }
334
346
  }
335
- function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacySkills = false) {
347
+ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots = false) {
336
348
  const installed = new Set();
337
349
  let hasSkills = false;
338
350
  let hasCommands = false;
@@ -345,7 +357,7 @@ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacySkills
345
357
  }
346
358
  else if (isSharedSkillTargetActive(projectPath, tool.value)) {
347
359
  skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
348
- if (includeLegacySkills) {
360
+ if (includeLegacyRoots) {
349
361
  skillsDirs.push(...(tool.legacySkillsDirs ?? []).map((root) => path.join(projectPath, root, 'skills')));
350
362
  }
351
363
  }
@@ -362,14 +374,32 @@ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacySkills
362
374
  const adapter = CommandAdapterRegistry.get(tool.value);
363
375
  if (!adapter)
364
376
  continue;
377
+ // A root the tool has moved away from still holds the command files the
378
+ // user installed there. Reading only the current root would report a
379
+ // commands install as skills-only, and delivery inferred from that answer
380
+ // deletes those commands on the next update.
381
+ const legacyRoots = includeLegacyRoots
382
+ ? (LEGACY_TOOL_ROOTS[tool.value] ?? []).map((legacy) => legacy.root)
383
+ : [];
365
384
  for (const workflowId of ALL_WORKFLOWS) {
366
385
  const commandPath = adapter.getFilePath(workflowId);
367
- const fullPath = path.isAbsolute(commandPath)
368
- ? commandPath
369
- : path.join(projectPath, commandPath);
370
- if (fs.existsSync(fullPath)) {
371
- installed.add(workflowId);
372
- hasCommands = true;
386
+ const candidates = [commandPath];
387
+ if (tool.skillsDir) {
388
+ for (const root of legacyRoots) {
389
+ const legacyPath = legacyCommandPath(commandPath, tool.skillsDir, root);
390
+ if (legacyPath)
391
+ candidates.push(legacyPath);
392
+ }
393
+ }
394
+ for (const candidate of candidates) {
395
+ const fullPath = path.isAbsolute(candidate)
396
+ ? candidate
397
+ : path.join(projectPath, candidate);
398
+ if (fs.existsSync(fullPath)) {
399
+ installed.add(workflowId);
400
+ hasCommands = true;
401
+ break;
402
+ }
373
403
  }
374
404
  }
375
405
  }
@@ -37,6 +37,7 @@ export interface DeltaPlan {
37
37
  added: RequirementBlock[];
38
38
  modified: RequirementBlock[];
39
39
  removed: string[];
40
+ removedBlocks: RequirementBlock[];
40
41
  renamed: Array<{
41
42
  from: string;
42
43
  to: string;
@@ -115,12 +115,14 @@ export function parseDeltaSpec(content) {
115
115
  sink: skippedHeaders,
116
116
  });
117
117
  const removedNames = parseRemovedNames(removedLookup.body);
118
+ const removedBlocks = parseRequirementBlocksFromSection(removedLookup.body);
118
119
  const renamedPairs = parseRenamedPairs(renamedLookup.body);
119
120
  skippedHeaders.sort((a, b) => a.line - b.line);
120
121
  return {
121
122
  added,
122
123
  modified,
123
124
  removed: removedNames,
125
+ removedBlocks,
124
126
  renamed: renamedPairs,
125
127
  skippedHeaders,
126
128
  sectionPresence: {
@@ -10,8 +10,8 @@ import { CommandAdapterRegistry, generateCommands } from '../command-generation/
10
10
  import { getCommandContents } from './skill-generation.js';
11
11
  import { getGlobalConfig } from '../global-config.js';
12
12
  import { getProfileWorkflows, ALL_WORKFLOWS } from '../profiles.js';
13
- import { isSharedSkillTargetActive, readSharedSkillTarget, reconcileSharedSkillTargets, } from '../shared-skill-target.js';
14
- import { shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, } from '../command-surface.js';
13
+ import { isSharedSkillTargetActive, hasLegacySkills, readSharedSkillTarget, reconcileSharedSkillTargets, } from '../shared-skill-target.js';
14
+ import { shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, resolveCommandSurfaceCapability, } from '../command-surface.js';
15
15
  import { getSkillCapableTools, resolveToolSkillsDir, toolSupportsSkills, } from './skill-paths.js';
16
16
  /**
17
17
  * Names of skill directories created by openspec init.
@@ -272,12 +272,18 @@ export function getConfiguredTools(projectRoot) {
272
272
  return false;
273
273
  return (getToolSkillStatus(projectRoot, t.value).configured ||
274
274
  toolHasAnyConfiguredCommand(projectRoot, t.value) ||
275
+ (resolveCommandSurfaceCapability(t.value) === 'adapter-backed' &&
276
+ hasLegacySkills(projectRoot, t)) ||
275
277
  (Boolean(t.skillsDir) &&
276
278
  readSharedSkillTarget(projectRoot, t.skillsDir) === t.value));
277
279
  });
278
280
  const activeProjectTools = new Set(reconcileSharedSkillTargets(projectRoot, configured.filter((tool) => tool.skillsDir)).map((tool) => tool.value));
279
281
  return configured
280
- .filter((tool) => tool.globalSkillsDir || activeProjectTools.has(tool.value))
282
+ .filter((tool) => tool.globalSkillsDir ||
283
+ toolHasAnyConfiguredCommand(projectRoot, tool.value) ||
284
+ (resolveCommandSurfaceCapability(tool.value) === 'adapter-backed' &&
285
+ hasLegacySkills(projectRoot, tool)) ||
286
+ activeProjectTools.has(tool.value))
281
287
  .map((tool) => tool.value);
282
288
  }
283
289
  /**
@@ -1,6 +1,19 @@
1
1
  import { type AIToolOption } from './config.js';
2
2
  /** Reads a valid-looking marker value without letting linked roots escape. */
3
3
  export declare function readSharedSkillTarget(projectPath: string, skillsDir: string): string | undefined;
4
+ /** Whether a tool still has an allowlisted managed skill under an old root. */
5
+ export declare function hasLegacySkills(projectPath: string, tool: AIToolOption): boolean;
6
+ /**
7
+ * Chooses the one selected tool that may render each physical skills root.
8
+ * Command files are not part of this arbitration: tools that share a skills
9
+ * root can still write their own command surface elsewhere under that root.
10
+ *
11
+ * Existing compatible owners win. On a new root, prefer a skills-native
12
+ * renderer over an adapter-backed renderer because its references remain
13
+ * usable by every consumer of the shared tree. Codex is the best fresh owner
14
+ * because its renderer includes both Codex and generic skill invocation forms.
15
+ */
16
+ export declare function resolveSharedSkillWriters(projectPath: string, tools: AIToolOption[]): Set<string>;
4
17
  /**
5
18
  * A shared skill root can only hold one rendered variant of each skill.
6
19
  * Keep the writer recorded so later updates do not infer every tool that
@@ -2,6 +2,7 @@ import path from 'path';
2
2
  import * as fs from 'fs';
3
3
  import { AI_TOOLS, OPENSPEC_SKILL_NAMES } from './config.js';
4
4
  import { FileSystemUtils } from '../utils/file-system.js';
5
+ import { resolveCommandSurfaceCapability } from './command-surface.js';
5
6
  const TARGET_MARKER = '.openspec-target';
6
7
  /** Returns the ownership-marker path for one shared skills root. */
7
8
  function markerPath(projectPath, skillsDir) {
@@ -19,7 +20,7 @@ export function readSharedSkillTarget(projectPath, skillsDir) {
19
20
  }
20
21
  }
21
22
  /** Whether a tool still has an allowlisted managed skill under an old root. */
22
- function hasLegacySkills(projectPath, tool) {
23
+ export function hasLegacySkills(projectPath, tool) {
23
24
  return (tool.legacySkillsDirs ?? []).some((root) => {
24
25
  const skillsDir = path.join(projectPath, root, 'skills');
25
26
  return OPENSPEC_SKILL_NAMES.some((skillName) => {
@@ -69,6 +70,47 @@ function hasCurrentSkills(projectPath, skillsDir) {
69
70
  }
70
71
  });
71
72
  }
73
+ /**
74
+ * Chooses the one selected tool that may render each physical skills root.
75
+ * Command files are not part of this arbitration: tools that share a skills
76
+ * root can still write their own command surface elsewhere under that root.
77
+ *
78
+ * Existing compatible owners win. On a new root, prefer a skills-native
79
+ * renderer over an adapter-backed renderer because its references remain
80
+ * usable by every consumer of the shared tree. Codex is the best fresh owner
81
+ * because its renderer includes both Codex and generic skill invocation forms.
82
+ */
83
+ export function resolveSharedSkillWriters(projectPath, tools) {
84
+ const byRoot = new Map();
85
+ const writers = new Set();
86
+ for (const tool of tools) {
87
+ if (!tool.skillsDir)
88
+ continue;
89
+ const group = byRoot.get(tool.skillsDir) ?? [];
90
+ group.push(tool);
91
+ byRoot.set(tool.skillsDir, group);
92
+ }
93
+ for (const [root, unsortedGroup] of byRoot) {
94
+ const group = [...unsortedGroup].sort((left, right) => AI_TOOLS.indexOf(left) - AI_TOOLS.indexOf(right));
95
+ if (group.length === 1) {
96
+ writers.add(group[0].value);
97
+ continue;
98
+ }
99
+ const skillsNative = group.filter((tool) => resolveCommandSurfaceCapability(tool.value) !== 'adapter-backed');
100
+ const preferredPool = skillsNative.length > 0 ? skillsNative : group;
101
+ const marked = readSharedSkillTarget(projectPath, root);
102
+ const inferred = inferSharedSkillTarget(projectPath, root);
103
+ const owner = preferredPool.find((tool) => tool.value === marked) ??
104
+ preferredPool.find((tool) => tool.value === inferred) ??
105
+ (hasCurrentSkills(projectPath, root)
106
+ ? preferredPool.find((tool) => tool.value === 'agents')
107
+ : undefined) ??
108
+ preferredPool.find((tool) => tool.value === 'codex') ??
109
+ preferredPool[0];
110
+ writers.add(owner.value);
111
+ }
112
+ return writers;
113
+ }
72
114
  /**
73
115
  * A shared skill root can only hold one rendered variant of each skill.
74
116
  * Keep the writer recorded so later updates do not infer every tool that
@@ -12,7 +12,7 @@ import { extractRequirementsSection, findMissingCurrentScenarios, foldRequiremen
12
12
  import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
13
13
  import { buildCodeFenceMask } from './parsers/code-fence.js';
14
14
  import { MarkdownParser } from './parsers/markdown-parser.js';
15
- import { MIN_PURPOSE_LENGTH } from './validation/constants.js';
15
+ import { MIN_PURPOSE_LENGTH, PURPOSE_PLACEHOLDER_PREFIX, PURPOSE_PLACEHOLDER_SUFFIX, } from './validation/constants.js';
16
16
  import { discoverSpecFiles } from '../utils/spec-discovery.js';
17
17
  import { FileSystemUtils } from '../utils/file-system.js';
18
18
  function isLexicallyWithin(allowedDirectory, targetPath) {
@@ -254,10 +254,12 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
254
254
  for (const block of parts.bodyBlocks) {
255
255
  nameToBlock.set(normalizeRequirementName(block.name), block);
256
256
  }
257
+ // Keep source blocks immutable for loss attribution. This parallel key list
258
+ // carries only positional identity as renames change lookup keys.
259
+ const orderedKeys = parts.bodyBlocks.map((block) => normalizeRequirementName(block.name));
257
260
  // Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED
258
261
  // RENAMED
259
262
  let renamedApplied = 0;
260
- const renamedTargets = new Map();
261
263
  for (const r of plan.renamed) {
262
264
  const from = normalizeRequirementName(r.from);
263
265
  const to = normalizeRequirementName(r.to);
@@ -291,7 +293,12 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
291
293
  };
292
294
  nameToBlock.delete(from);
293
295
  nameToBlock.set(to, renamedBlock);
294
- renamedTargets.set(from, to);
296
+ // A Map delete+set moves the renamed block to insertion-order tail. Carry
297
+ // its new key in the source slot instead; chained renames update it again.
298
+ const orderIndex = orderedKeys.indexOf(from);
299
+ if (orderIndex >= 0) {
300
+ orderedKeys[orderIndex] = to;
301
+ }
295
302
  renamedApplied++;
296
303
  }
297
304
  // REMOVED
@@ -364,8 +371,9 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
364
371
  // Recompose requirements section preserving original ordering where possible
365
372
  const keptOrder = [];
366
373
  const seen = new Set();
367
- for (const block of parts.bodyBlocks) {
368
- const key = normalizeRequirementName(block.name);
374
+ for (let index = 0; index < parts.bodyBlocks.length; index++) {
375
+ const block = parts.bodyBlocks[index];
376
+ const key = orderedKeys[index];
369
377
  const replacement = nameToBlock.get(key);
370
378
  if (replacement) {
371
379
  keptOrder.push(replacement);
@@ -377,8 +385,7 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
377
385
  // full absorbed suffix. RENAMED carries the original raw content under a
378
386
  // new map key, and MODIFIED may repeat the suffix deliberately; neither is
379
387
  // data loss.
380
- const renamedTarget = renamedTargets.get(key);
381
- const replacementFromOriginal = replacement ?? (renamedTarget ? nameToBlock.get(renamedTarget) : undefined);
388
+ const replacementFromOriginal = replacement;
382
389
  if (replacementFromOriginal !== block) {
383
390
  const foreign = firstForeignTail(block.raw);
384
391
  const replacementRaw = replacementFromOriginal?.raw;
@@ -917,7 +924,8 @@ function readableOverview(skeleton, specName) {
917
924
  */
918
925
  export function buildSpecSkeleton(specFolderName, changeName, purpose) {
919
926
  const titleBase = specFolderName;
920
- const purposeBody = purpose?.trim() || `TBD - created by archiving change ${changeName}. Update Purpose after archive.`;
927
+ const purposeBody = purpose?.trim() ||
928
+ `${PURPOSE_PLACEHOLDER_PREFIX}${changeName}${PURPOSE_PLACEHOLDER_SUFFIX}`;
921
929
  return `# ${titleBase} Specification\n\n## Purpose\n${purposeBody}\n\n## Requirements\n`;
922
930
  }
923
931
  //# sourceMappingURL=specs-apply.js.map