@fission-ai/openspec 1.7.0 → 1.9.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 +3 -1
- package/dist/cli/index.d.ts +15 -0
- package/dist/cli/index.js +39 -5
- package/dist/commands/change.js +16 -1
- package/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.js +5 -1
- package/dist/commands/schema.js +281 -47
- package/dist/commands/spec.js +30 -6
- package/dist/commands/validate.d.ts +23 -0
- package/dist/commands/validate.js +148 -4
- package/dist/commands/workflow/instructions.js +32 -27
- package/dist/commands/workflow/schemas.d.ts +2 -0
- package/dist/commands/workflow/schemas.js +9 -2
- package/dist/commands/workflow/status.js +2 -2
- package/dist/commands/workflow/templates.js +15 -5
- package/dist/core/archive.d.ts +14 -0
- package/dist/core/archive.js +1272 -151
- package/dist/core/artifact-graph/index.d.ts +1 -1
- package/dist/core/artifact-graph/index.js +1 -1
- package/dist/core/artifact-graph/instruction-loader.d.ts +3 -1
- package/dist/core/artifact-graph/instruction-loader.js +15 -5
- package/dist/core/artifact-graph/outputs.d.ts +1 -0
- package/dist/core/artifact-graph/outputs.js +67 -5
- package/dist/core/artifact-graph/resolver.d.ts +0 -12
- package/dist/core/artifact-graph/resolver.js +49 -9
- package/dist/core/artifact-graph/types.js +21 -3
- package/dist/core/available-tools.d.ts +2 -2
- package/dist/core/available-tools.js +14 -3
- package/dist/core/change-metadata/schema.d.ts +1 -0
- package/dist/core/change-metadata/schema.js +7 -0
- package/dist/core/change-status-policy.js +2 -2
- package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
- package/dist/core/command-generation/adapters/command-code.js +34 -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 +13 -0
- package/dist/core/config-schema.d.ts +3 -0
- package/dist/core/config-schema.js +28 -1
- package/dist/core/config.d.ts +4 -0
- package/dist/core/config.js +40 -17
- package/dist/core/file-state.js +56 -24
- package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
- package/dist/core/github-copilot/cloud-agent.js +497 -0
- package/dist/core/global-config.d.ts +11 -0
- package/dist/core/init.d.ts +23 -0
- package/dist/core/init.js +253 -46
- package/dist/core/legacy-cleanup.d.ts +10 -0
- package/dist/core/legacy-cleanup.js +52 -28
- package/dist/core/migration.d.ts +6 -4
- package/dist/core/migration.js +78 -22
- package/dist/core/parsers/requirement-blocks.d.ts +10 -0
- package/dist/core/parsers/requirement-blocks.js +93 -1
- package/dist/core/parsers/requirement-text.d.ts +8 -0
- package/dist/core/parsers/requirement-text.js +4 -3
- package/dist/core/parsers/spec-structure.d.ts +1 -1
- package/dist/core/parsers/spec-structure.js +17 -1
- package/dist/core/profile-sync-drift.js +40 -5
- package/dist/core/project-config.d.ts +3 -0
- package/dist/core/project-config.js +31 -1
- package/dist/core/root-selection.d.ts +1 -1
- package/dist/core/shared/index.d.ts +1 -0
- package/dist/core/shared/index.js +1 -0
- package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
- package/dist/core/shared/skill-content-equivalence.js +41 -0
- package/dist/core/shared/skill-paths.d.ts +13 -0
- package/dist/core/shared/skill-paths.js +23 -0
- package/dist/core/shared/tool-detection.js +66 -35
- package/dist/core/shared-skill-target.d.ts +31 -0
- package/dist/core/shared-skill-target.js +186 -0
- package/dist/core/specs-apply.d.ts +72 -0
- package/dist/core/specs-apply.js +459 -59
- package/dist/core/templates/skill-templates.d.ts +1 -1
- package/dist/core/templates/skill-templates.js +1 -1
- package/dist/core/templates/workflows/apply-change.d.ts +10 -0
- package/dist/core/templates/workflows/apply-change.js +25 -182
- package/dist/core/templates/workflows/archive-change.js +8 -4
- package/dist/core/templates/workflows/bulk-archive-change.js +30 -26
- package/dist/core/templates/workflows/continue-change.js +6 -6
- package/dist/core/templates/workflows/explore.js +42 -18
- package/dist/core/templates/workflows/onboard.js +9 -3
- package/dist/core/templates/workflows/propose.js +72 -20
- package/dist/core/templates/workflows/store-selection.d.ts +1 -1
- package/dist/core/templates/workflows/store-selection.js +1 -1
- package/dist/core/templates/workflows/sync-specs.js +96 -18
- package/dist/core/templates/workflows/update-change.js +10 -8
- package/dist/core/update.d.ts +1 -0
- package/dist/core/update.js +136 -30
- package/dist/core/validation/task-numbering.d.ts +16 -0
- package/dist/core/validation/task-numbering.js +57 -0
- package/dist/core/validation/validator.d.ts +26 -3
- package/dist/core/validation/validator.js +189 -19
- package/dist/core/version-check.js +6 -11
- package/dist/telemetry/config.d.ts +3 -4
- package/dist/telemetry/index.d.ts +12 -5
- package/dist/telemetry/index.js +27 -9
- package/dist/utils/change-metadata.d.ts +17 -4
- package/dist/utils/change-metadata.js +26 -4
- package/dist/utils/ci.d.ts +12 -0
- package/dist/utils/ci.js +16 -0
- package/dist/utils/command-references.d.ts +14 -2
- package/dist/utils/command-references.js +45 -3
- package/dist/utils/file-system.d.ts +10 -0
- package/dist/utils/file-system.js +70 -0
- package/dist/utils/interactive.d.ts +52 -0
- package/dist/utils/interactive.js +135 -0
- package/dist/utils/spec-discovery.d.ts +4 -4
- package/dist/utils/spec-discovery.js +19 -6
- package/dist/utils/task-progress.d.ts +51 -1
- package/dist/utils/task-progress.js +93 -41
- package/package.json +1 -1
- package/schemas/spec-driven/schema.yaml +8 -6
- package/schemas/spec-driven/templates/proposal.md +6 -4
package/dist/core/migration.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface LegacyToolRoot {
|
|
|
14
14
|
* location may still be the live one for somebody.
|
|
15
15
|
*/
|
|
16
16
|
needsConsent: boolean;
|
|
17
|
+
/** Migrations that need a freshly generated destination run afterward. */
|
|
18
|
+
timing?: 'before-generation' | 'after-generation';
|
|
17
19
|
}
|
|
18
20
|
/**
|
|
19
21
|
* Former tool roots whose OpenSpec-managed content belongs under the tool's
|
|
@@ -32,8 +34,8 @@ export interface LegacyToolMigration {
|
|
|
32
34
|
commandFiles: number;
|
|
33
35
|
/**
|
|
34
36
|
* OpenSpec-managed files left under the legacy root because the copy there
|
|
35
|
-
* differs from the one that survives
|
|
36
|
-
*
|
|
37
|
+
* differs materially from the one that survives, so it is reported rather
|
|
38
|
+
* than dropped.
|
|
37
39
|
*/
|
|
38
40
|
keptInPlace: number;
|
|
39
41
|
/** Whether this move needs the user's consent first */
|
|
@@ -43,7 +45,7 @@ export interface LegacyToolMigration {
|
|
|
43
45
|
* Reports the OpenSpec content sitting under each tool's legacy root, without
|
|
44
46
|
* moving anything. Callers use this to ask before a move that needs consent.
|
|
45
47
|
*/
|
|
46
|
-
export declare function findLegacyToolMigrations(projectPath: string): LegacyToolMigration[];
|
|
48
|
+
export declare function findLegacyToolMigrations(projectPath: string, timing?: 'before-generation' | 'after-generation'): LegacyToolMigration[];
|
|
47
49
|
/**
|
|
48
50
|
* Moves OpenSpec-managed skill directories (openspec-*) and command files
|
|
49
51
|
* (opsx-*) from a tool's legacy root to its current one. When the destination
|
|
@@ -55,7 +57,7 @@ export declare function findLegacyToolMigrations(projectPath: string): LegacyToo
|
|
|
55
57
|
* @param toolIds - Restrict the move to these tools; omit to move every tool
|
|
56
58
|
* whose legacy root needs no consent
|
|
57
59
|
*/
|
|
58
|
-
export declare function migrateLegacyToolDirs(projectPath: string, toolIds?: string[]): LegacyToolMigration[];
|
|
60
|
+
export declare function migrateLegacyToolDirs(projectPath: string, toolIds?: string[], timing?: 'before-generation' | 'after-generation'): LegacyToolMigration[];
|
|
59
61
|
/**
|
|
60
62
|
* Summarizes what a migration moved, e.g. "6 skills and 6 commands".
|
|
61
63
|
*/
|
package/dist/core/migration.js
CHANGED
|
@@ -12,8 +12,12 @@ import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js';
|
|
|
12
12
|
import { COMMAND_IDS } from './shared/tool-detection.js';
|
|
13
13
|
import { ALL_WORKFLOWS } from './profiles.js';
|
|
14
14
|
import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js';
|
|
15
|
+
import { FileSystemUtils } from '../utils/file-system.js';
|
|
16
|
+
import { isSharedSkillTargetActive } from './shared-skill-target.js';
|
|
17
|
+
import { isLegacyCodexSkillEquivalentToCurrent } from './shared/skill-content-equivalence.js';
|
|
15
18
|
import path from 'path';
|
|
16
19
|
import * as fs from 'fs';
|
|
20
|
+
import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.js';
|
|
17
21
|
/**
|
|
18
22
|
* Former tool roots whose OpenSpec-managed content belongs under the tool's
|
|
19
23
|
* current skillsDir. User files are never touched.
|
|
@@ -27,6 +31,9 @@ export const LEGACY_TOOL_ROOTS = {
|
|
|
27
31
|
// default — but a pre-rebrand Windsurf build reads ONLY .windsurf/, and
|
|
28
32
|
// nothing on disk tells that user apart, so the move is offered, not taken.
|
|
29
33
|
devin: [{ root: '.windsurf', needsConsent: true }],
|
|
34
|
+
// Codex now reads the canonical shared .agents root. Generate the current
|
|
35
|
+
// replacement first so a divergent legacy file is preserved, not overwritten.
|
|
36
|
+
codex: [{ root: '.codex', needsConsent: false, timing: 'after-generation' }],
|
|
30
37
|
};
|
|
31
38
|
function classifyManagedFile(source, destination) {
|
|
32
39
|
if (isSamePath(source, destination))
|
|
@@ -34,7 +41,12 @@ function classifyManagedFile(source, destination) {
|
|
|
34
41
|
if (!fs.existsSync(destination))
|
|
35
42
|
return 'move';
|
|
36
43
|
try {
|
|
37
|
-
|
|
44
|
+
const sourceContent = fs.readFileSync(source, 'utf-8');
|
|
45
|
+
const destinationContent = fs.readFileSync(destination, 'utf-8');
|
|
46
|
+
const equivalentGeneratedSkills = path.basename(source) === 'SKILL.md' &&
|
|
47
|
+
path.basename(destination) === 'SKILL.md' &&
|
|
48
|
+
isLegacyCodexSkillEquivalentToCurrent(sourceContent, destinationContent);
|
|
49
|
+
return sourceContent === destinationContent || equivalentGeneratedSkills
|
|
38
50
|
? 'drop'
|
|
39
51
|
: 'keep';
|
|
40
52
|
}
|
|
@@ -64,8 +76,8 @@ function legacyCommandPath(commandPath, currentRoot, legacyRoot) {
|
|
|
64
76
|
* Reports the OpenSpec content sitting under each tool's legacy root, without
|
|
65
77
|
* moving anything. Callers use this to ask before a move that needs consent.
|
|
66
78
|
*/
|
|
67
|
-
export function findLegacyToolMigrations(projectPath) {
|
|
68
|
-
return collectLegacyToolMigrations(projectPath, false);
|
|
79
|
+
export function findLegacyToolMigrations(projectPath, timing = 'before-generation') {
|
|
80
|
+
return collectLegacyToolMigrations(projectPath, false, undefined, timing);
|
|
69
81
|
}
|
|
70
82
|
/**
|
|
71
83
|
* Moves OpenSpec-managed skill directories (openspec-*) and command files
|
|
@@ -78,10 +90,10 @@ export function findLegacyToolMigrations(projectPath) {
|
|
|
78
90
|
* @param toolIds - Restrict the move to these tools; omit to move every tool
|
|
79
91
|
* whose legacy root needs no consent
|
|
80
92
|
*/
|
|
81
|
-
export function migrateLegacyToolDirs(projectPath, toolIds) {
|
|
82
|
-
return collectLegacyToolMigrations(projectPath, true, toolIds);
|
|
93
|
+
export function migrateLegacyToolDirs(projectPath, toolIds, timing = 'before-generation') {
|
|
94
|
+
return collectLegacyToolMigrations(projectPath, true, toolIds, timing);
|
|
83
95
|
}
|
|
84
|
-
function collectLegacyToolMigrations(projectPath, apply, toolIds) {
|
|
96
|
+
function collectLegacyToolMigrations(projectPath, apply, toolIds, timing = 'before-generation') {
|
|
85
97
|
const migrations = [];
|
|
86
98
|
for (const tool of AI_TOOLS) {
|
|
87
99
|
if (!tool.skillsDir)
|
|
@@ -89,19 +101,31 @@ function collectLegacyToolMigrations(projectPath, apply, toolIds) {
|
|
|
89
101
|
if (toolIds && !toolIds.includes(tool.value))
|
|
90
102
|
continue;
|
|
91
103
|
for (const legacy of LEGACY_TOOL_ROOTS[tool.value] ?? []) {
|
|
104
|
+
const legacyTiming = legacy.timing ?? 'before-generation';
|
|
105
|
+
if (legacyTiming !== timing)
|
|
106
|
+
continue;
|
|
92
107
|
if (legacy.root === tool.skillsDir)
|
|
93
108
|
continue;
|
|
94
109
|
// Without an explicit tool list, only moves that need no consent run.
|
|
95
110
|
if (apply && !toolIds && legacy.needsConsent)
|
|
96
111
|
continue;
|
|
97
|
-
|
|
112
|
+
const legacyRootPath = path.join(projectPath, legacy.root);
|
|
113
|
+
if (!fs.existsSync(legacyRootPath))
|
|
114
|
+
continue;
|
|
115
|
+
try {
|
|
116
|
+
FileSystemUtils.assertProjectArtifactPath(projectPath, legacyRootPath);
|
|
117
|
+
FileSystemUtils.assertProjectArtifactPath(projectPath, path.join(projectPath, tool.skillsDir));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
console.warn(`Skipping legacy ${legacy.root}/ migration because the directory resolves outside this project.`);
|
|
98
121
|
continue;
|
|
99
|
-
|
|
122
|
+
}
|
|
123
|
+
const skills = migrateSkillDirs(projectPath, tool.skillsDir, legacy.root, apply, legacyTiming === 'after-generation');
|
|
100
124
|
const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply);
|
|
101
125
|
if (apply) {
|
|
102
|
-
removeDirIfEmpty(path.join(
|
|
103
|
-
removeDirIfEmpty(path.join(
|
|
104
|
-
removeDirIfEmpty(
|
|
126
|
+
removeDirIfEmpty(path.join(legacyRootPath, 'skills'));
|
|
127
|
+
removeDirIfEmpty(path.join(legacyRootPath, 'workflows'));
|
|
128
|
+
removeDirIfEmpty(legacyRootPath);
|
|
105
129
|
}
|
|
106
130
|
// Kept-only results are retained deliberately. When every legacy file
|
|
107
131
|
// differs from its counterpart nothing is movable, and dropping the
|
|
@@ -122,7 +146,7 @@ function collectLegacyToolMigrations(projectPath, apply, toolIds) {
|
|
|
122
146
|
}
|
|
123
147
|
return migrations;
|
|
124
148
|
}
|
|
125
|
-
function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply) {
|
|
149
|
+
function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDestination = false) {
|
|
126
150
|
const legacySkillsDir = path.join(projectPath, legacyRoot, 'skills');
|
|
127
151
|
if (!fs.existsSync(legacySkillsDir))
|
|
128
152
|
return { moved: 0, kept: 0 };
|
|
@@ -137,6 +161,12 @@ function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply) {
|
|
|
137
161
|
continue;
|
|
138
162
|
const destination = path.join(currentSkillsDir, dirName);
|
|
139
163
|
const destinationSkill = path.join(destination, 'SKILL.md');
|
|
164
|
+
if (requireDestination && !fs.existsSync(destinationSkill))
|
|
165
|
+
continue;
|
|
166
|
+
if (!areProjectArtifacts(projectPath, sourceSkill, destinationSkill)) {
|
|
167
|
+
console.warn(`Skipping legacy ${legacyRoot}/skills/${dirName} migration because it resolves outside this project.`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
140
170
|
const disposition = classifyManagedFile(sourceSkill, destinationSkill);
|
|
141
171
|
if (disposition === 'skip')
|
|
142
172
|
continue;
|
|
@@ -186,6 +216,10 @@ function migrateCommandFiles(projectPath, tool, legacyRoot, apply) {
|
|
|
186
216
|
if (!fs.existsSync(source))
|
|
187
217
|
continue;
|
|
188
218
|
const destination = path.join(projectPath, currentPath);
|
|
219
|
+
if (!areProjectArtifacts(projectPath, source, destination)) {
|
|
220
|
+
console.warn(`Skipping legacy ${legacyPath} migration because it resolves outside this project.`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
189
223
|
const disposition = classifyManagedFile(source, destination);
|
|
190
224
|
if (disposition === 'skip')
|
|
191
225
|
continue;
|
|
@@ -277,6 +311,17 @@ function isSamePath(a, b) {
|
|
|
277
311
|
return false;
|
|
278
312
|
}
|
|
279
313
|
}
|
|
314
|
+
function areProjectArtifacts(projectPath, ...artifactPaths) {
|
|
315
|
+
try {
|
|
316
|
+
for (const artifactPath of artifactPaths) {
|
|
317
|
+
FileSystemUtils.assertProjectArtifactPath(projectPath, artifactPath);
|
|
318
|
+
}
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
280
325
|
function removeDirIfEmpty(dirPath) {
|
|
281
326
|
try {
|
|
282
327
|
if (fs.readdirSync(dirPath).length === 0) {
|
|
@@ -287,20 +332,31 @@ function removeDirIfEmpty(dirPath) {
|
|
|
287
332
|
// Missing or non-empty directory — nothing to do
|
|
288
333
|
}
|
|
289
334
|
}
|
|
290
|
-
function scanInstalledWorkflowArtifacts(projectPath, tools) {
|
|
335
|
+
function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacySkills = false) {
|
|
291
336
|
const installed = new Set();
|
|
292
337
|
let hasSkills = false;
|
|
293
338
|
let hasCommands = false;
|
|
294
339
|
for (const tool of tools) {
|
|
295
|
-
if (!tool
|
|
340
|
+
if (!toolSupportsSkills(tool))
|
|
296
341
|
continue;
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
342
|
+
const skillsDirs = [];
|
|
343
|
+
if (tool.globalSkillsDir) {
|
|
344
|
+
skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
|
|
345
|
+
}
|
|
346
|
+
else if (isSharedSkillTargetActive(projectPath, tool.value)) {
|
|
347
|
+
skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
|
|
348
|
+
if (includeLegacySkills) {
|
|
349
|
+
skillsDirs.push(...(tool.legacySkillsDirs ?? []).map((root) => path.join(projectPath, root, 'skills')));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
for (const skillsDir of skillsDirs) {
|
|
353
|
+
for (const workflowId of ALL_WORKFLOWS) {
|
|
354
|
+
const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId];
|
|
355
|
+
const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md');
|
|
356
|
+
if (fs.existsSync(skillFile)) {
|
|
357
|
+
installed.add(workflowId);
|
|
358
|
+
hasSkills = true;
|
|
359
|
+
}
|
|
304
360
|
}
|
|
305
361
|
}
|
|
306
362
|
const adapter = CommandAdapterRegistry.get(tool.value);
|
|
@@ -366,7 +422,7 @@ export function migrateIfNeeded(projectPath, tools) {
|
|
|
366
422
|
return;
|
|
367
423
|
}
|
|
368
424
|
// Scan for installed workflows
|
|
369
|
-
const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools);
|
|
425
|
+
const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools, true);
|
|
370
426
|
const installedWorkflows = artifacts.workflows;
|
|
371
427
|
if (installedWorkflows.length === 0) {
|
|
372
428
|
// No workflows installed, new user — defaults will apply
|
|
@@ -53,4 +53,14 @@ export interface DeltaPlan {
|
|
|
53
53
|
* Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
|
|
54
54
|
*/
|
|
55
55
|
export declare function parseDeltaSpec(content: string): DeltaPlan;
|
|
56
|
+
/**
|
|
57
|
+
* Scenario names the current requirement block has and the incoming
|
|
58
|
+
* (MODIFIED) block does not. A MODIFIED requirement replaces the whole block,
|
|
59
|
+
* so every name reported here would be dropped from the main spec.
|
|
60
|
+
*
|
|
61
|
+
* Shared by archive (which refuses to apply the block) and validate (which
|
|
62
|
+
* reports the same loss at authoring time, #1477), so the two cannot disagree
|
|
63
|
+
* about what counts as a dropped scenario.
|
|
64
|
+
*/
|
|
65
|
+
export declare function findMissingCurrentScenarios(current: RequirementBlock, incoming: RequirementBlock): string[];
|
|
56
66
|
//# sourceMappingURL=requirement-blocks.d.ts.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildCodeFenceMask } from './requirement-text.js';
|
|
1
|
+
import { buildCodeFenceMask, SCENARIO_HEADER } from './requirement-text.js';
|
|
2
2
|
export function normalizeRequirementName(name) {
|
|
3
3
|
return name.trim();
|
|
4
4
|
}
|
|
@@ -257,4 +257,96 @@ function parseRenamedPairs(sectionBody) {
|
|
|
257
257
|
}
|
|
258
258
|
return pairs;
|
|
259
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* Scenario names the current requirement block has and the incoming
|
|
262
|
+
* (MODIFIED) block does not. A MODIFIED requirement replaces the whole block,
|
|
263
|
+
* so every name reported here would be dropped from the main spec.
|
|
264
|
+
*
|
|
265
|
+
* Shared by archive (which refuses to apply the block) and validate (which
|
|
266
|
+
* reports the same loss at authoring time, #1477), so the two cannot disagree
|
|
267
|
+
* about what counts as a dropped scenario.
|
|
268
|
+
*/
|
|
269
|
+
export function findMissingCurrentScenarios(current, incoming) {
|
|
270
|
+
// Multiplicity-aware: a name present N times in current and M times in
|
|
271
|
+
// incoming means max(0, N - M) instances are missing. Set membership would
|
|
272
|
+
// treat N>M as fully covered and let archive silently drop duplicates
|
|
273
|
+
// (residual #1246 / duplicate-scenario-name blind spot).
|
|
274
|
+
const remainingIncoming = new Map();
|
|
275
|
+
for (const scenario of parseScenarioBlocks(incoming.raw)) {
|
|
276
|
+
const name = scenario.name;
|
|
277
|
+
remainingIncoming.set(name, (remainingIncoming.get(name) ?? 0) + 1);
|
|
278
|
+
}
|
|
279
|
+
const missing = [];
|
|
280
|
+
for (const scenario of parseScenarioBlocks(current.raw)) {
|
|
281
|
+
const name = scenario.name;
|
|
282
|
+
const remaining = remainingIncoming.get(name) ?? 0;
|
|
283
|
+
if (remaining > 0) {
|
|
284
|
+
remainingIncoming.set(name, remaining - 1);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
missing.push(name);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return missing;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Any non-fenced level-4 header on the given (masked) line. Reuses the spec
|
|
294
|
+
* path's SCENARIO_HEADER so the two counters cannot drift apart.
|
|
295
|
+
*/
|
|
296
|
+
function scenarioHeaderAt(lines, mask, index) {
|
|
297
|
+
return !mask[index] && SCENARIO_HEADER.test(lines[index]);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* The scenario name for a `#### ` header, matching the label the author reads:
|
|
301
|
+
* the header text with the leading `####`, an optional CommonMark closing `#`
|
|
302
|
+
* run (`#### Foo ####` renders as `Foo`), and an optional `Scenario:` prefix
|
|
303
|
+
* stripped. Both the current and incoming blocks run through here, so the
|
|
304
|
+
* comparison in findMissingCurrentScenarios stays internally consistent
|
|
305
|
+
* regardless of label — and two headers that render to the same title (one
|
|
306
|
+
* ATX-closed, one not) are not mistaken for a dropped scenario.
|
|
307
|
+
*/
|
|
308
|
+
function scenarioNameAt(line) {
|
|
309
|
+
return line
|
|
310
|
+
.replace(SCENARIO_HEADER, '')
|
|
311
|
+
// Optional ATX closing sequence. CommonMark only treats a trailing `#` run
|
|
312
|
+
// as a close when it is preceded by a space or tab — not any Unicode space —
|
|
313
|
+
// so this uses `[ \t]`, not `\s`. A looser `\s` could strip a `#` run after
|
|
314
|
+
// an exotic space (e.g. NBSP) that CommonMark keeps, folding two distinct
|
|
315
|
+
// scenario names into one and masking a real loss. `[ \t]` keeps the fold
|
|
316
|
+
// faithful to how the header actually renders.
|
|
317
|
+
.replace(/[ \t]+#+[ \t]*$/, '')
|
|
318
|
+
.replace(/^Scenario:\s*/i, '')
|
|
319
|
+
.trim();
|
|
320
|
+
}
|
|
321
|
+
function parseScenarioBlocks(requirementRaw) {
|
|
322
|
+
const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n');
|
|
323
|
+
// A scenario is ANY non-fenced `#### ` header, matching the spec path's
|
|
324
|
+
// SCENARIO_HEADER / countScenarios (requirement-text.ts) exactly — not only
|
|
325
|
+
// `#### Scenario:`. The two MUST agree: a level-4 child whose header is not
|
|
326
|
+
// literally `Scenario:` (e.g. `#### Edge case`) is still a scenario the spec
|
|
327
|
+
// path counts, so a MODIFIED block that drops it would otherwise slip past
|
|
328
|
+
// this loss check and be deleted by archive with no error (the parity the
|
|
329
|
+
// SCENARIO_HEADER comment warns not to break). A `####` inside a fenced
|
|
330
|
+
// example is masked out, matching countScenarios.
|
|
331
|
+
const mask = buildCodeFenceMask(lines);
|
|
332
|
+
const scenarios = [];
|
|
333
|
+
let index = 0;
|
|
334
|
+
while (index < lines.length) {
|
|
335
|
+
if (!scenarioHeaderAt(lines, mask, index)) {
|
|
336
|
+
index++;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const start = index;
|
|
340
|
+
const name = scenarioNameAt(lines[index]);
|
|
341
|
+
index++;
|
|
342
|
+
while (index < lines.length && !scenarioHeaderAt(lines, mask, index)) {
|
|
343
|
+
index++;
|
|
344
|
+
}
|
|
345
|
+
scenarios.push({
|
|
346
|
+
name,
|
|
347
|
+
raw: lines.slice(start, index).join('\n').trimEnd(),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return scenarios;
|
|
351
|
+
}
|
|
260
352
|
//# sourceMappingURL=requirement-blocks.js.map
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
* `validate <change>`, `validate <spec>`, and `archive`.
|
|
10
10
|
*/
|
|
11
11
|
export { buildCodeFenceMask } from './code-fence.js';
|
|
12
|
+
/**
|
|
13
|
+
* A level-4 header. Deliberately matches ANY `####` header, not only
|
|
14
|
+
* `#### Scenario:` — the spec path treats every level-4 child of a requirement
|
|
15
|
+
* as a scenario, so the delta counter must too (parity). The delta/loss path
|
|
16
|
+
* reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
|
|
17
|
+
* keep both paths on it rather than reintroducing a separate `Scenario:` regex.
|
|
18
|
+
*/
|
|
19
|
+
export declare const SCENARIO_HEADER: RegExp;
|
|
12
20
|
/**
|
|
13
21
|
* The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
|
|
14
22
|
* as whole words so the change-delta reader and the schema-based reader accept
|
|
@@ -19,10 +19,11 @@ const HEADER_LINE = /^#{1,6}\s/;
|
|
|
19
19
|
/**
|
|
20
20
|
* A level-4 header. Deliberately matches ANY `####` header, not only
|
|
21
21
|
* `#### Scenario:` — the spec path treats every level-4 child of a requirement
|
|
22
|
-
* as a scenario, so the delta counter must too (parity).
|
|
23
|
-
*
|
|
22
|
+
* as a scenario, so the delta counter must too (parity). The delta/loss path
|
|
23
|
+
* reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
|
|
24
|
+
* keep both paths on it rather than reintroducing a separate `Scenario:` regex.
|
|
24
25
|
*/
|
|
25
|
-
const SCENARIO_HEADER = /^####\s+/;
|
|
26
|
+
export const SCENARIO_HEADER = /^####\s+/;
|
|
26
27
|
/**
|
|
27
28
|
* The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
|
|
28
29
|
* as whole words so the change-delta reader and the schema-based reader accept
|
|
@@ -8,6 +8,7 @@ export function findMainSpecStructureIssues(content) {
|
|
|
8
8
|
const stripped = stripFencedCodeBlocksPreservingLines(normalized);
|
|
9
9
|
const lines = stripped.split('\n');
|
|
10
10
|
const issues = [];
|
|
11
|
+
const requirementLines = new Map();
|
|
11
12
|
const requirementsHeaderIndex = lines.findIndex(line => REQUIREMENTS_SECTION_HEADER.test(line));
|
|
12
13
|
let requirementsEndIndex = lines.length;
|
|
13
14
|
if (requirementsHeaderIndex !== -1) {
|
|
@@ -30,7 +31,7 @@ export function findMainSpecStructureIssues(content) {
|
|
|
30
31
|
line: i + 1,
|
|
31
32
|
header: trimmed,
|
|
32
33
|
message: `Main spec contains delta header "${trimmed}". ` +
|
|
33
|
-
'Delta headers are only valid inside openspec/changes/<name>/specs/<capability>/spec.md ' +
|
|
34
|
+
'Delta headers are only valid inside openspec/changes/<name>/specs/<capability-path>/spec.md ' +
|
|
34
35
|
'and truncate the parsed ## Requirements section.',
|
|
35
36
|
});
|
|
36
37
|
continue;
|
|
@@ -50,6 +51,21 @@ export function findMainSpecStructureIssues(content) {
|
|
|
50
51
|
message: `Requirement header "${trimmed}" appears outside the main ## Requirements section. ` +
|
|
51
52
|
'Main specs only parse requirements inside that section, so this requirement is currently invisible to validate, list, and archive.',
|
|
52
53
|
});
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const requirementName = requirementMatch[1].trim();
|
|
57
|
+
const previousLine = requirementLines.get(requirementName);
|
|
58
|
+
if (previousLine !== undefined) {
|
|
59
|
+
issues.push({
|
|
60
|
+
kind: 'duplicate-requirement',
|
|
61
|
+
line: i + 1,
|
|
62
|
+
header: trimmed,
|
|
63
|
+
message: `Requirement header "${trimmed}" duplicates the requirement declared on line ${previousLine}. ` +
|
|
64
|
+
'Requirement names must be unique so spec updates cannot discard one block while updating another.',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
requirementLines.set(requirementName, i + 1);
|
|
53
69
|
}
|
|
54
70
|
}
|
|
55
71
|
return issues;
|
|
@@ -5,6 +5,10 @@ import { ALL_WORKFLOWS } from './profiles.js';
|
|
|
5
5
|
import { CommandAdapterRegistry } from './command-generation/index.js';
|
|
6
6
|
import { getConfiguredTools } from './shared/index.js';
|
|
7
7
|
import { shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js';
|
|
8
|
+
import { readSharedSkillTarget } from './shared-skill-target.js';
|
|
9
|
+
import { FileSystemUtils } from '../utils/file-system.js';
|
|
10
|
+
import { isLegacyCodexSkillEquivalentToCurrent } from './shared/skill-content-equivalence.js';
|
|
11
|
+
import { hasGlobalSkillTarget, resolveToolSkillsDir, toolSupportsSkills, } from './shared/skill-paths.js';
|
|
8
12
|
/**
|
|
9
13
|
* Maps workflow IDs to their skill directory names.
|
|
10
14
|
*/
|
|
@@ -41,14 +45,45 @@ export function getConfiguredToolsForProfileSync(projectPath) {
|
|
|
41
45
|
*/
|
|
42
46
|
export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkflows, delivery) {
|
|
43
47
|
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
|
44
|
-
if (!tool
|
|
48
|
+
if (!tool || !toolSupportsSkills(tool))
|
|
45
49
|
return false;
|
|
46
50
|
const knownDesiredWorkflows = toKnownWorkflows(desiredWorkflows);
|
|
47
51
|
const desiredWorkflowSet = new Set(knownDesiredWorkflows);
|
|
48
|
-
const skillsDir =
|
|
52
|
+
const skillsDir = resolveToolSkillsDir(projectPath, tool);
|
|
49
53
|
const adapter = CommandAdapterRegistry.get(toolId);
|
|
50
54
|
const shouldGenerateSkills = shouldGenerateSkillsForTool(toolId, delivery);
|
|
51
55
|
const shouldGenerateCommands = shouldGenerateCommandsForTool(toolId, delivery);
|
|
56
|
+
const sharedTarget = tool.skillsDir
|
|
57
|
+
? readSharedSkillTarget(projectPath, tool.skillsDir)
|
|
58
|
+
: undefined;
|
|
59
|
+
for (const root of tool.legacySkillsDirs ?? []) {
|
|
60
|
+
for (const workflow of knownDesiredWorkflows) {
|
|
61
|
+
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
|
62
|
+
const legacySkill = path.join(projectPath, root, 'skills', dirName, 'SKILL.md');
|
|
63
|
+
if (!fs.existsSync(legacySkill))
|
|
64
|
+
continue;
|
|
65
|
+
const currentSkill = path.join(skillsDir, dirName, 'SKILL.md');
|
|
66
|
+
if (!fs.existsSync(currentSkill) || sharedTarget !== toolId) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
if (FileSystemUtils.canonicalizeExistingPath(legacySkill) ===
|
|
71
|
+
FileSystemUtils.canonicalizeExistingPath(currentSkill)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// Equivalent generated copies are actionable: migration can safely
|
|
75
|
+
// remove the redundant legacy file even when version, line endings,
|
|
76
|
+
// or supported invocation syntax changed. Materially divergent copies
|
|
77
|
+
// stay in place without forcing an update on every run.
|
|
78
|
+
if (isLegacyCodexSkillEquivalentToCurrent(fs.readFileSync(legacySkill, 'utf-8'), fs.readFileSync(currentSkill, 'utf-8'))) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
52
87
|
if (shouldGenerateSkills) {
|
|
53
88
|
for (const workflow of knownDesiredWorkflows) {
|
|
54
89
|
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
|
@@ -68,7 +103,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
|
|
|
68
103
|
}
|
|
69
104
|
}
|
|
70
105
|
}
|
|
71
|
-
else if (shouldRemoveSkillsForTool(toolId, delivery)) {
|
|
106
|
+
else if (shouldRemoveSkillsForTool(toolId, delivery) && !hasGlobalSkillTarget(tool)) {
|
|
72
107
|
for (const workflow of ALL_WORKFLOWS) {
|
|
73
108
|
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
|
74
109
|
const skillDir = path.join(skillsDir, dirName);
|
|
@@ -116,10 +151,10 @@ export function getToolsNeedingProfileSync(projectPath, desiredWorkflows, delive
|
|
|
116
151
|
}
|
|
117
152
|
function getInstalledWorkflowsForTool(projectPath, toolId, options) {
|
|
118
153
|
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
|
119
|
-
if (!tool
|
|
154
|
+
if (!tool || !toolSupportsSkills(tool))
|
|
120
155
|
return [];
|
|
121
156
|
const installed = new Set();
|
|
122
|
-
const skillsDir =
|
|
157
|
+
const skillsDir = resolveToolSkillsDir(projectPath, tool);
|
|
123
158
|
if (options.includeSkills) {
|
|
124
159
|
for (const workflow of ALL_WORKFLOWS) {
|
|
125
160
|
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
|
@@ -31,6 +31,9 @@ export declare const ProjectConfigSchema: z.ZodObject<{
|
|
|
31
31
|
}, z.core.$strip>>;
|
|
32
32
|
}, z.core.$strip>>;
|
|
33
33
|
store: z.ZodOptional<z.ZodString>;
|
|
34
|
+
githubCopilot: z.ZodOptional<z.ZodObject<{
|
|
35
|
+
cloudAgent: z.ZodOptional<z.ZodBoolean>;
|
|
36
|
+
}, z.core.$strip>>;
|
|
34
37
|
}, z.core.$strip>;
|
|
35
38
|
/** Normalized in-memory shape of a referenced store declaration. */
|
|
36
39
|
export interface DeclarationEntry {
|
|
@@ -57,6 +57,15 @@ export const ProjectConfigSchema = z.object({
|
|
|
57
57
|
.string()
|
|
58
58
|
.optional()
|
|
59
59
|
.describe('Store id used as the OpenSpec root when no local planning shape exists'),
|
|
60
|
+
// Optional: GitHub Copilot integration preferences. `cloudAgent` is the
|
|
61
|
+
// opt-in for generating the Copilot cloud coding-agent files (a GitHub
|
|
62
|
+
// Actions workflow + agent file); absent means "not yet decided".
|
|
63
|
+
githubCopilot: z
|
|
64
|
+
.object({
|
|
65
|
+
cloudAgent: z.boolean().optional(),
|
|
66
|
+
})
|
|
67
|
+
.optional()
|
|
68
|
+
.describe('GitHub Copilot integration preferences'),
|
|
60
69
|
});
|
|
61
70
|
export function loadOperationInputs(projectConfig, operationId) {
|
|
62
71
|
const context = projectConfig?.context !== undefined && projectConfig.context.trim().length > 0
|
|
@@ -235,7 +244,11 @@ export function readProjectConfig(projectRoot) {
|
|
|
235
244
|
const rulesField = z.record(z.string(), z.array(z.string()));
|
|
236
245
|
// First check if it's an object structure (guard against null since typeof null === 'object')
|
|
237
246
|
if (typeof raw.rules === 'object' && raw.rules !== null && !Array.isArray(raw.rules)) {
|
|
238
|
-
|
|
247
|
+
// Artifact ids are intentionally not restricted to the built-in naming
|
|
248
|
+
// convention, so keys such as "constructor" remain valid for custom
|
|
249
|
+
// schemas. A null-prototype map preserves those keys as data without
|
|
250
|
+
// letting "__proto__" mutate the lookup object's prototype.
|
|
251
|
+
const parsedRules = Object.create(null);
|
|
239
252
|
let hasValidRules = false;
|
|
240
253
|
for (const [artifactId, rules] of Object.entries(raw.rules)) {
|
|
241
254
|
const rulesArrayResult = z.array(z.string()).safeParse(rules);
|
|
@@ -281,6 +294,23 @@ export function readProjectConfig(projectRoot) {
|
|
|
281
294
|
console.warn(`Warning: ignoring invalid store: field in ${configPathForWarnings(projectRoot)} (must be a single store id string).`);
|
|
282
295
|
}
|
|
283
296
|
}
|
|
297
|
+
// Parse githubCopilot preferences (only cloudAgent is recognized today).
|
|
298
|
+
if (raw.githubCopilot !== undefined) {
|
|
299
|
+
if (typeof raw.githubCopilot === 'object' &&
|
|
300
|
+
raw.githubCopilot !== null &&
|
|
301
|
+
!Array.isArray(raw.githubCopilot)) {
|
|
302
|
+
const cloudAgent = raw.githubCopilot.cloudAgent;
|
|
303
|
+
if (typeof cloudAgent === 'boolean') {
|
|
304
|
+
config.githubCopilot = { cloudAgent };
|
|
305
|
+
}
|
|
306
|
+
else if (cloudAgent !== undefined) {
|
|
307
|
+
console.warn(`Invalid 'githubCopilot.cloudAgent' field in config (must be a boolean)`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
console.warn(`Invalid 'githubCopilot' field in config (must be an object)`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
284
314
|
// Return partial config even if some fields failed
|
|
285
315
|
return Object.keys(config).length > 0 ? config : null;
|
|
286
316
|
}
|
|
@@ -119,7 +119,7 @@ export declare function toPlanningHome(root: ResolvedOpenSpecRoot): PlanningHome
|
|
|
119
119
|
export declare function resolveRootForCommand(selector: StoreSelectorOptions, output?: {
|
|
120
120
|
json?: boolean;
|
|
121
121
|
failurePayload?: Record<string, unknown>;
|
|
122
|
-
/**
|
|
122
|
+
/** Commands that require an existing root set this to false. */
|
|
123
123
|
allowImplicitRoot?: boolean;
|
|
124
124
|
}): Promise<ResolvedOpenSpecRoot | null>;
|
|
125
125
|
//# sourceMappingURL=root-selection.d.ts.map
|
|
@@ -5,4 +5,5 @@
|
|
|
5
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
|
+
export { type SkillCapableTool, toolSupportsSkills, getSkillCapableTools, hasGlobalSkillTarget, resolveToolSkillsDir, } from './skill-paths.js';
|
|
8
9
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -5,4 +5,5 @@
|
|
|
5
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
|
+
export { toolSupportsSkills, getSkillCapableTools, hasGlobalSkillTarget, resolveToolSkillsDir, } from './skill-paths.js';
|
|
8
9
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns whether a legacy Codex skill differs from the current canonical
|
|
3
|
+
* replacement only by generated version, checkout line endings/BOM, or the
|
|
4
|
+
* known Codex/generic dual-reference expansion.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isLegacyCodexSkillEquivalentToCurrent(legacyContent: string, currentContent: string): boolean;
|
|
7
|
+
//# sourceMappingURL=skill-content-equivalence.d.ts.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { OPENSPEC_SKILL_NAMES } from '../config.js';
|
|
2
|
+
const GENERATED_VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
3
|
+
const OPENSPEC_SKILL_NAME_SET = new Set(OPENSPEC_SKILL_NAMES);
|
|
4
|
+
/**
|
|
5
|
+
* Normalizes checkout line endings and a valid generated version inside the
|
|
6
|
+
* YAML frontmatter. Free-form `generatedBy` text in the instructions remains
|
|
7
|
+
* material.
|
|
8
|
+
*/
|
|
9
|
+
function normalizeGeneratedSkill(content) {
|
|
10
|
+
const normalized = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
|
|
11
|
+
const frontmatter = normalized.match(/^---\n[\s\S]*?\n---(?:\n|$)/)?.[0];
|
|
12
|
+
if (!frontmatter)
|
|
13
|
+
return normalized;
|
|
14
|
+
const versionLine = /^(\s*generatedBy:\s*)(?:"([^"\n]+)"|'([^'\n]+)'|([^\s"'#]+))\s*$/m;
|
|
15
|
+
const normalizedFrontmatter = frontmatter.replace(versionLine, (line, prefix, doubleQuoted, singleQuoted, bare) => {
|
|
16
|
+
const version = doubleQuoted ?? singleQuoted ?? bare;
|
|
17
|
+
return version && GENERATED_VERSION.test(version)
|
|
18
|
+
? `${prefix}"<generated-version>"`
|
|
19
|
+
: line;
|
|
20
|
+
});
|
|
21
|
+
return normalizedFrontmatter + normalized.slice(frontmatter.length);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Converts only known generated dual references in current Codex content back
|
|
25
|
+
* to the direct syntax used by legacy `.codex` output.
|
|
26
|
+
*/
|
|
27
|
+
function toLegacyCodexReferences(content) {
|
|
28
|
+
return content.replace(/\$(openspec-[a-z0-9-]+) \(Codex\) or \/\1 \(other agents\)/g, (match, skillName) => OPENSPEC_SKILL_NAME_SET.has(skillName) ? `$${skillName}` : match);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns whether a legacy Codex skill differs from the current canonical
|
|
32
|
+
* replacement only by generated version, checkout line endings/BOM, or the
|
|
33
|
+
* known Codex/generic dual-reference expansion.
|
|
34
|
+
*/
|
|
35
|
+
export function isLegacyCodexSkillEquivalentToCurrent(legacyContent, currentContent) {
|
|
36
|
+
const normalizedLegacy = normalizeGeneratedSkill(legacyContent);
|
|
37
|
+
const normalizedCurrent = normalizeGeneratedSkill(currentContent);
|
|
38
|
+
return (normalizedLegacy === normalizedCurrent ||
|
|
39
|
+
normalizedLegacy === toLegacyCodexReferences(normalizedCurrent));
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=skill-content-equivalence.js.map
|