@fission-ai/openspec 1.10.0 → 1.12.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 (59) hide show
  1. package/README.md +1 -0
  2. package/dist/cli/index.js +12 -2
  3. package/dist/commands/change.d.ts +24 -0
  4. package/dist/commands/change.js +229 -5
  5. package/dist/commands/schema.d.ts +5 -0
  6. package/dist/commands/schema.js +201 -35
  7. package/dist/commands/show.js +1 -1
  8. package/dist/commands/validate.d.ts +57 -0
  9. package/dist/commands/validate.js +99 -13
  10. package/dist/commands/workflow/index.d.ts +1 -1
  11. package/dist/commands/workflow/index.js +1 -1
  12. package/dist/commands/workflow/status.d.ts +2 -0
  13. package/dist/commands/workflow/status.js +76 -12
  14. package/dist/core/artifact-graph/resolver.js +15 -9
  15. package/dist/core/available-tools.js +16 -1
  16. package/dist/core/command-generation/adapters/antigravity.d.ts +1 -1
  17. package/dist/core/command-generation/adapters/antigravity.js +2 -2
  18. package/dist/core/command-generation/adapters/codeassistant.d.ts +15 -0
  19. package/dist/core/command-generation/adapters/codeassistant.js +29 -0
  20. package/dist/core/command-generation/adapters/index.d.ts +1 -0
  21. package/dist/core/command-generation/adapters/index.js +1 -0
  22. package/dist/core/command-generation/registry.js +2 -0
  23. package/dist/core/completions/command-registry.js +21 -0
  24. package/dist/core/completions/generators/fish-generator.d.ts +12 -0
  25. package/dist/core/completions/generators/fish-generator.js +79 -47
  26. package/dist/core/completions/templates/fish-templates.d.ts +1 -1
  27. package/dist/core/completions/templates/fish-templates.js +107 -10
  28. package/dist/core/completions/types.d.ts +4 -0
  29. package/dist/core/config.js +7 -1
  30. package/dist/core/init.d.ts +1 -0
  31. package/dist/core/init.js +71 -65
  32. package/dist/core/legacy-cleanup.js +6 -0
  33. package/dist/core/migration.js +42 -12
  34. package/dist/core/openspec-root.d.ts +1 -0
  35. package/dist/core/openspec-root.js +10 -2
  36. package/dist/core/parsers/requirement-blocks.d.ts +1 -0
  37. package/dist/core/parsers/requirement-blocks.js +2 -0
  38. package/dist/core/shared/ide-restart.d.ts +26 -0
  39. package/dist/core/shared/ide-restart.js +42 -0
  40. package/dist/core/shared/index.d.ts +1 -0
  41. package/dist/core/shared/index.js +1 -0
  42. package/dist/core/shared/tool-detection.js +9 -3
  43. package/dist/core/shared-skill-target.d.ts +13 -0
  44. package/dist/core/shared-skill-target.js +43 -1
  45. package/dist/core/specs-apply.js +22 -9
  46. package/dist/core/templates/workflows/explore.js +108 -73
  47. package/dist/core/templates/workflows/ff-change.js +8 -0
  48. package/dist/core/templates/workflows/propose.js +8 -0
  49. package/dist/core/update.js +28 -31
  50. package/dist/core/validation/constants.d.ts +3 -0
  51. package/dist/core/validation/constants.js +12 -0
  52. package/dist/core/validation/purpose-placeholder.d.ts +45 -0
  53. package/dist/core/validation/purpose-placeholder.js +117 -0
  54. package/dist/core/validation/validator.d.ts +12 -3
  55. package/dist/core/validation/validator.js +90 -4
  56. package/dist/utils/command-references.js +7 -8
  57. package/dist/utils/requirement-diff.d.ts +42 -0
  58. package/dist/utils/requirement-diff.js +61 -0
  59. package/package.json +4 -3
@@ -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 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,
@@ -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: {
@@ -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
@@ -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) {
@@ -214,7 +214,12 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
214
214
  }
215
215
  }
216
216
  }
217
- catch {
217
+ catch (error) {
218
+ // An unreadable target is not a new spec. Preserve the filesystem error
219
+ // for callers, rather than synthesizing a baseline or a missing-target finding.
220
+ const code = error?.code;
221
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
222
+ throw error;
218
223
  // Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs
219
224
  // REMOVED will be ignored with a warning since there's nothing to remove
220
225
  if (plan.modified.length > 0 || plan.renamed.length > 0) {
@@ -254,10 +259,12 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
254
259
  for (const block of parts.bodyBlocks) {
255
260
  nameToBlock.set(normalizeRequirementName(block.name), block);
256
261
  }
262
+ // Keep source blocks immutable for loss attribution. This parallel key list
263
+ // carries only positional identity as renames change lookup keys.
264
+ const orderedKeys = parts.bodyBlocks.map((block) => normalizeRequirementName(block.name));
257
265
  // Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED
258
266
  // RENAMED
259
267
  let renamedApplied = 0;
260
- const renamedTargets = new Map();
261
268
  for (const r of plan.renamed) {
262
269
  const from = normalizeRequirementName(r.from);
263
270
  const to = normalizeRequirementName(r.to);
@@ -291,7 +298,12 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
291
298
  };
292
299
  nameToBlock.delete(from);
293
300
  nameToBlock.set(to, renamedBlock);
294
- renamedTargets.set(from, to);
301
+ // A Map delete+set moves the renamed block to insertion-order tail. Carry
302
+ // its new key in the source slot instead; chained renames update it again.
303
+ const orderIndex = orderedKeys.indexOf(from);
304
+ if (orderIndex >= 0) {
305
+ orderedKeys[orderIndex] = to;
306
+ }
295
307
  renamedApplied++;
296
308
  }
297
309
  // REMOVED
@@ -364,8 +376,9 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
364
376
  // Recompose requirements section preserving original ordering where possible
365
377
  const keptOrder = [];
366
378
  const seen = new Set();
367
- for (const block of parts.bodyBlocks) {
368
- const key = normalizeRequirementName(block.name);
379
+ for (let index = 0; index < parts.bodyBlocks.length; index++) {
380
+ const block = parts.bodyBlocks[index];
381
+ const key = orderedKeys[index];
369
382
  const replacement = nameToBlock.get(key);
370
383
  if (replacement) {
371
384
  keptOrder.push(replacement);
@@ -377,8 +390,7 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
377
390
  // full absorbed suffix. RENAMED carries the original raw content under a
378
391
  // new map key, and MODIFIED may repeat the suffix deliberately; neither is
379
392
  // data loss.
380
- const renamedTarget = renamedTargets.get(key);
381
- const replacementFromOriginal = replacement ?? (renamedTarget ? nameToBlock.get(renamedTarget) : undefined);
393
+ const replacementFromOriginal = replacement;
382
394
  if (replacementFromOriginal !== block) {
383
395
  const foreign = firstForeignTail(block.raw);
384
396
  const replacementRaw = replacementFromOriginal?.raw;
@@ -917,7 +929,8 @@ function readableOverview(skeleton, specName) {
917
929
  */
918
930
  export function buildSpecSkeleton(specFolderName, changeName, purpose) {
919
931
  const titleBase = specFolderName;
920
- const purposeBody = purpose?.trim() || `TBD - created by archiving change ${changeName}. Update Purpose after archive.`;
932
+ const purposeBody = purpose?.trim() ||
933
+ `${PURPOSE_PLACEHOLDER_PREFIX}${changeName}${PURPOSE_PLACEHOLDER_SUFFIX}`;
921
934
  return `# ${titleBase} Specification\n\n## Purpose\n${purposeBody}\n\n## Requirements\n`;
922
935
  }
923
936
  //# sourceMappingURL=specs-apply.js.map