@fission-ai/openspec 1.7.0 → 1.8.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.js +6 -1
- package/dist/commands/change.js +15 -1
- package/dist/commands/schema.js +89 -20
- package/dist/commands/spec.js +30 -6
- package/dist/commands/validate.js +2 -2
- package/dist/commands/workflow/instructions.js +32 -27
- 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 +1265 -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.js +35 -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/completions/command-registry.js +8 -0
- package/dist/core/config-schema.d.ts +3 -0
- package/dist/core/config-schema.js +28 -1
- package/dist/core/config.d.ts +3 -0
- package/dist/core/config.js +24 -2
- 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 +234 -39
- package/dist/core/legacy-cleanup.js +4 -2
- 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 +60 -0
- 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/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 +16 -0
- package/dist/core/shared-skill-target.js +156 -0
- package/dist/core/specs-apply.d.ts +72 -0
- package/dist/core/specs-apply.js +448 -55
- 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 +22 -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 +103 -24
- package/dist/core/validation/validator.d.ts +23 -3
- package/dist/core/validation/validator.js +134 -18
- package/dist/core/version-check.js +6 -11
- package/dist/telemetry/config.d.ts +3 -4
- package/dist/telemetry/index.d.ts +9 -4
- package/dist/telemetry/index.js +20 -8
- 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 +23 -0
- package/dist/utils/interactive.js +32 -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 +17 -0
- package/dist/utils/task-progress.js +41 -13
- 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.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
|
|
@@ -257,4 +257,64 @@ 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
|
+
function parseScenarioBlocks(requirementRaw) {
|
|
293
|
+
const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n');
|
|
294
|
+
// A `#### Scenario:` inside a fenced example is not a real scenario. The
|
|
295
|
+
// validator's countScenarios already ignores fenced lines; the drift check
|
|
296
|
+
// must agree with it, or a fenced sample can false-abort an archive (or
|
|
297
|
+
// mask a genuinely dropped scenario).
|
|
298
|
+
const mask = buildCodeFenceMask(lines);
|
|
299
|
+
const scenarios = [];
|
|
300
|
+
let index = 0;
|
|
301
|
+
while (index < lines.length) {
|
|
302
|
+
const headerMatch = mask[index] ? null : lines[index].match(/^####\s*Scenario:\s*(.+)\s*$/);
|
|
303
|
+
if (!headerMatch) {
|
|
304
|
+
index++;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const start = index;
|
|
308
|
+
const name = headerMatch[1].trim();
|
|
309
|
+
index++;
|
|
310
|
+
while (index < lines.length && (mask[index] || !/^####\s*Scenario:\s*(.+)\s*$/.test(lines[index]))) {
|
|
311
|
+
index++;
|
|
312
|
+
}
|
|
313
|
+
scenarios.push({
|
|
314
|
+
name,
|
|
315
|
+
raw: lines.slice(start, index).join('\n').trimEnd(),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return scenarios;
|
|
319
|
+
}
|
|
260
320
|
//# sourceMappingURL=requirement-blocks.js.map
|
|
@@ -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
|
}
|
|
@@ -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
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type AIToolOption } from '../config.js';
|
|
2
|
+
export type SkillCapableTool = AIToolOption & ({
|
|
3
|
+
skillsDir: string;
|
|
4
|
+
} | {
|
|
5
|
+
globalSkillsDir: string;
|
|
6
|
+
});
|
|
7
|
+
export declare function toolSupportsSkills(tool: AIToolOption): tool is SkillCapableTool;
|
|
8
|
+
export declare function getSkillCapableTools(): SkillCapableTool[];
|
|
9
|
+
export declare function hasGlobalSkillTarget(tool: AIToolOption): boolean;
|
|
10
|
+
export declare function resolveToolSkillsDir(projectRoot: string, tool: SkillCapableTool, options?: {
|
|
11
|
+
homeDir?: string;
|
|
12
|
+
}): string;
|
|
13
|
+
//# sourceMappingURL=skill-paths.d.ts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { AI_TOOLS } from '../config.js';
|
|
4
|
+
export function toolSupportsSkills(tool) {
|
|
5
|
+
return Boolean(tool.skillsDir || tool.globalSkillsDir);
|
|
6
|
+
}
|
|
7
|
+
export function getSkillCapableTools() {
|
|
8
|
+
return AI_TOOLS.filter(toolSupportsSkills);
|
|
9
|
+
}
|
|
10
|
+
export function hasGlobalSkillTarget(tool) {
|
|
11
|
+
return Boolean(tool.globalSkillsDir);
|
|
12
|
+
}
|
|
13
|
+
export function resolveToolSkillsDir(projectRoot, tool, options = {}) {
|
|
14
|
+
if (tool.globalSkillsDir) {
|
|
15
|
+
const homeDir = options.homeDir ?? process.env.USERPROFILE ?? process.env.HOME ?? os.homedir();
|
|
16
|
+
return path.join(homeDir, tool.globalSkillsDir, 'skills');
|
|
17
|
+
}
|
|
18
|
+
if (tool.skillsDir) {
|
|
19
|
+
return path.join(projectRoot, tool.skillsDir, 'skills');
|
|
20
|
+
}
|
|
21
|
+
throw new Error(`Tool '${tool.value}' does not support skill generation.`);
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=skill-paths.js.map
|