@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.
- package/dist/cli/index.js +11 -2
- package/dist/commands/change.d.ts +24 -0
- package/dist/commands/change.js +223 -0
- 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/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/completions/command-registry.js +15 -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 +6 -1
- package/dist/core/init.js +50 -31
- package/dist/core/legacy-cleanup.js +6 -0
- package/dist/core/migration.js +42 -12
- package/dist/core/parsers/requirement-blocks.d.ts +1 -0
- package/dist/core/parsers/requirement-blocks.js +2 -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 +16 -8
- package/dist/core/templates/workflows/explore.js +79 -73
- package/dist/core/update.js +24 -22
- 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.js +16 -1
- package/dist/utils/requirement-diff.d.ts +42 -0
- package/dist/utils/requirement-diff.js +61 -0
- 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
|
*/
|
package/dist/core/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
530
|
-
const
|
|
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) &&
|
|
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'] },
|
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
|
}
|
|
@@ -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 ||
|
|
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) {
|
|
@@ -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
|
-
|
|
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 (
|
|
368
|
-
const
|
|
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
|
|
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() ||
|
|
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
|