@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.
- package/README.md +1 -0
- package/dist/cli/index.js +12 -2
- package/dist/commands/change.d.ts +24 -0
- package/dist/commands/change.js +229 -5
- package/dist/commands/schema.d.ts +5 -0
- package/dist/commands/schema.js +201 -35
- package/dist/commands/show.js +1 -1
- package/dist/commands/validate.d.ts +57 -0
- package/dist/commands/validate.js +99 -13
- package/dist/commands/workflow/index.d.ts +1 -1
- package/dist/commands/workflow/index.js +1 -1
- package/dist/commands/workflow/status.d.ts +2 -0
- package/dist/commands/workflow/status.js +76 -12
- package/dist/core/artifact-graph/resolver.js +15 -9
- package/dist/core/available-tools.js +16 -1
- package/dist/core/command-generation/adapters/antigravity.d.ts +1 -1
- package/dist/core/command-generation/adapters/antigravity.js +2 -2
- package/dist/core/command-generation/adapters/codeassistant.d.ts +15 -0
- package/dist/core/command-generation/adapters/codeassistant.js +29 -0
- package/dist/core/command-generation/adapters/index.d.ts +1 -0
- package/dist/core/command-generation/adapters/index.js +1 -0
- package/dist/core/command-generation/registry.js +2 -0
- package/dist/core/completions/command-registry.js +21 -0
- package/dist/core/completions/generators/fish-generator.d.ts +12 -0
- package/dist/core/completions/generators/fish-generator.js +79 -47
- package/dist/core/completions/templates/fish-templates.d.ts +1 -1
- package/dist/core/completions/templates/fish-templates.js +107 -10
- package/dist/core/completions/types.d.ts +4 -0
- package/dist/core/config.js +7 -1
- package/dist/core/init.d.ts +1 -0
- package/dist/core/init.js +71 -65
- package/dist/core/legacy-cleanup.js +6 -0
- package/dist/core/migration.js +42 -12
- package/dist/core/openspec-root.d.ts +1 -0
- package/dist/core/openspec-root.js +10 -2
- package/dist/core/parsers/requirement-blocks.d.ts +1 -0
- package/dist/core/parsers/requirement-blocks.js +2 -0
- package/dist/core/shared/ide-restart.d.ts +26 -0
- package/dist/core/shared/ide-restart.js +42 -0
- package/dist/core/shared/index.d.ts +1 -0
- package/dist/core/shared/index.js +1 -0
- package/dist/core/shared/tool-detection.js +9 -3
- package/dist/core/shared-skill-target.d.ts +13 -0
- package/dist/core/shared-skill-target.js +43 -1
- package/dist/core/specs-apply.js +22 -9
- package/dist/core/templates/workflows/explore.js +108 -73
- package/dist/core/templates/workflows/ff-change.js +8 -0
- package/dist/core/templates/workflows/propose.js +8 -0
- package/dist/core/update.js +28 -31
- package/dist/core/validation/constants.d.ts +3 -0
- package/dist/core/validation/constants.js +12 -0
- package/dist/core/validation/purpose-placeholder.d.ts +45 -0
- package/dist/core/validation/purpose-placeholder.js +117 -0
- package/dist/core/validation/validator.d.ts +12 -3
- package/dist/core/validation/validator.js +90 -4
- package/dist/utils/command-references.js +7 -8
- package/dist/utils/requirement-diff.d.ts +42 -0
- package/dist/utils/requirement-diff.js +61 -0
- 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'] },
|
package/dist/core/migration.js
CHANGED
|
@@ -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 =
|
|
221
|
+
const source = FileSystemUtils.resolveProjectArtifactPath(projectPath, legacyPath);
|
|
216
222
|
if (!fs.existsSync(source))
|
|
217
223
|
continue;
|
|
218
|
-
const destination =
|
|
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,
|
|
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 (
|
|
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
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -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 ||
|
|
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
|
package/dist/core/specs-apply.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
368
|
-
const
|
|
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
|
|
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() ||
|
|
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
|