@fission-ai/openspec 1.14.3 → 1.16.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 +12 -10
- package/dist/cli/index.js +47 -0
- package/dist/commands/completion.js +4 -2
- package/dist/commands/feedback.js +33 -20
- package/dist/commands/install-standalone.d.ts +27 -0
- package/dist/commands/install-standalone.js +150 -0
- package/dist/commands/schema.js +10 -3
- package/dist/commands/validate.js +33 -3
- package/dist/commands/workflow/instructions.js +15 -4
- package/dist/commands/workflow/new-change.js +12 -4
- package/dist/commands/workflow/status.js +5 -2
- package/dist/commands/workflow/templates.js +5 -2
- package/dist/core/artifact-graph/resolver.d.ts +6 -0
- package/dist/core/artifact-graph/resolver.js +11 -0
- package/dist/core/available-tools.d.ts +2 -1
- package/dist/core/available-tools.js +8 -28
- package/dist/core/completion-tip.d.ts +23 -0
- package/dist/core/completion-tip.js +4 -4
- package/dist/core/completions/command-registry.js +15 -0
- package/dist/core/completions/installers/zsh-installer.d.ts +9 -1
- package/dist/core/completions/installers/zsh-installer.js +64 -19
- package/dist/core/config-schema.d.ts +1 -0
- package/dist/core/config-schema.js +1 -0
- package/dist/core/file-view.d.ts +13 -0
- package/dist/core/file-view.js +14 -0
- package/dist/core/github-copilot/cloud-agent.d.ts +10 -2
- package/dist/core/github-copilot/cloud-agent.js +13 -9
- package/dist/core/global-config.d.ts +4 -0
- package/dist/core/global-config.js +5 -1
- package/dist/core/id.d.ts +8 -0
- package/dist/core/id.js +15 -0
- package/dist/core/legacy-cleanup.d.ts +11 -1
- package/dist/core/legacy-cleanup.js +15 -18
- package/dist/core/migration.d.ts +27 -1
- package/dist/core/migration.js +77 -29
- package/dist/core/parsers/code-fence.js +9 -1
- package/dist/core/parsers/requirement-blocks.d.ts +30 -0
- package/dist/core/parsers/requirement-blocks.js +22 -9
- package/dist/core/parsers/requirement-text.d.ts +14 -0
- package/dist/core/parsers/requirement-text.js +39 -0
- package/dist/core/parsers/spec-structure.js +1 -1
- package/dist/core/profile-sync-drift.d.ts +4 -3
- package/dist/core/profile-sync-drift.js +19 -16
- package/dist/core/project-config.d.ts +10 -0
- package/dist/core/project-config.js +26 -0
- package/dist/core/root-selection.d.ts +6 -0
- package/dist/core/root-selection.js +37 -0
- package/dist/core/shared/tool-detection.d.ts +12 -4
- package/dist/core/shared/tool-detection.js +29 -27
- package/dist/core/shared-skill-target.d.ts +12 -5
- package/dist/core/shared-skill-target.js +34 -23
- package/dist/core/standalone-tip.d.ts +6 -0
- package/dist/core/standalone-tip.js +38 -0
- package/dist/core/store/git.d.ts +22 -1
- package/dist/core/store/git.js +59 -4
- package/dist/core/templates/workflows/verify-change.js +4 -4
- package/dist/core/update-plan.d.ts +127 -0
- package/dist/core/update-plan.js +390 -0
- package/dist/core/update.d.ts +45 -1
- package/dist/core/update.js +279 -137
- package/dist/core/validation/validator.d.ts +27 -2
- package/dist/core/validation/validator.js +58 -4
- package/dist/core/version-check.d.ts +8 -1
- package/dist/core/version-check.js +48 -5
- package/dist/utils/executable-path.d.ts +5 -0
- package/dist/utils/executable-path.js +44 -0
- package/package.json +5 -4
- package/schemas/spec-driven/schema.yaml +1 -0
package/dist/core/id.js
CHANGED
|
@@ -6,6 +6,21 @@ export const KEBAB_ID_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
|
6
6
|
export function isKebabId(value) {
|
|
7
7
|
return KEBAB_ID_REGEX.test(value);
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Best-effort normalization of an arbitrary name into the kebab grammar:
|
|
11
|
+
* lowercases, splits camelCase/PascalCase boundaries, and folds every
|
|
12
|
+
* non-alphanumeric run into a single hyphen. Used to turn a git repository
|
|
13
|
+
* name into a candidate store id; the result is only ever a candidate and
|
|
14
|
+
* is validated with {@link isKebabId} before use.
|
|
15
|
+
*/
|
|
16
|
+
export function toKebabCase(value) {
|
|
17
|
+
return value
|
|
18
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
19
|
+
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
|
|
20
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
21
|
+
.replace(/^-+|-+$/g, '')
|
|
22
|
+
.toLowerCase();
|
|
23
|
+
}
|
|
9
24
|
/** Human rendering of the grammar, shared so the wording never forks. */
|
|
10
25
|
export const KEBAB_ID_DESCRIPTION = 'must be kebab-case with lowercase letters, numbers, and single hyphen separators';
|
|
11
26
|
/** The fix-line twin of KEBAB_ID_DESCRIPTION, shared for the same reason. */
|
|
@@ -166,7 +166,17 @@ export interface CleanupResult {
|
|
|
166
166
|
* @param detection - Detection result from detectLegacyArtifacts
|
|
167
167
|
* @returns Cleanup result with summary of actions taken
|
|
168
168
|
*/
|
|
169
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Every change cleanup makes on disk. `update --dry-run` passes a recorder, so
|
|
171
|
+
* the plan comes from this same code rather than a description of it.
|
|
172
|
+
*/
|
|
173
|
+
export interface LegacyCleanupOps {
|
|
174
|
+
writeFile(filePath: string, content: string): Promise<void>;
|
|
175
|
+
removeFile(filePath: string): Promise<void>;
|
|
176
|
+
/** Removes a directory already known to be empty. */
|
|
177
|
+
removeEmptyDirectory(dirPath: string): Promise<void>;
|
|
178
|
+
}
|
|
179
|
+
export declare function cleanupLegacyArtifacts(projectPath: string, detection: LegacyDetectionResult, ops?: LegacyCleanupOps): Promise<CleanupResult>;
|
|
170
180
|
/**
|
|
171
181
|
* Generates a cleanup summary message for display.
|
|
172
182
|
*
|
|
@@ -323,14 +323,14 @@ function legacyCommandDirForFile(file) {
|
|
|
323
323
|
* or records what is left in it as kept. Never recursive: whatever remains was
|
|
324
324
|
* not written by OpenSpec. Returns true when the directory was removed.
|
|
325
325
|
*/
|
|
326
|
-
async function settleLegacyCommandDir(projectPath, dirPath, result) {
|
|
326
|
+
async function settleLegacyCommandDir(projectPath, dirPath, result, ops = DIRECT_CLEANUP_OPS) {
|
|
327
327
|
const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
|
|
328
328
|
const remaining = await readLegacyCommandDir(fullPath, []);
|
|
329
329
|
if (!remaining) {
|
|
330
330
|
return false;
|
|
331
331
|
}
|
|
332
332
|
if (remaining.others.length === 0) {
|
|
333
|
-
await
|
|
333
|
+
await ops.removeEmptyDirectory(fullPath);
|
|
334
334
|
result.deletedDirs.push(dirPath);
|
|
335
335
|
return true;
|
|
336
336
|
}
|
|
@@ -493,15 +493,12 @@ export function isOnlyOpenSpecContent(content) {
|
|
|
493
493
|
export function removeMarkerBlock(content) {
|
|
494
494
|
return removeMarkerBlockUtil(content, OPENSPEC_MARKERS.start, OPENSPEC_MARKERS.end);
|
|
495
495
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
* @returns Cleanup result with summary of actions taken
|
|
503
|
-
*/
|
|
504
|
-
export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
496
|
+
const DIRECT_CLEANUP_OPS = {
|
|
497
|
+
writeFile: (filePath, content) => FileSystemUtils.writeFile(filePath, content),
|
|
498
|
+
removeFile: (filePath) => fs.unlink(filePath),
|
|
499
|
+
removeEmptyDirectory: (dirPath) => fs.rmdir(dirPath),
|
|
500
|
+
};
|
|
501
|
+
export async function cleanupLegacyArtifacts(projectPath, detection, ops = DIRECT_CLEANUP_OPS) {
|
|
505
502
|
const result = {
|
|
506
503
|
deletedFiles: [],
|
|
507
504
|
deletedFileReplacementLabels: {},
|
|
@@ -519,7 +516,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
519
516
|
const content = await FileSystemUtils.readFile(filePath);
|
|
520
517
|
const newContent = removeMarkerBlock(content);
|
|
521
518
|
// Always write the file, even if empty - never delete user config files
|
|
522
|
-
await
|
|
519
|
+
await ops.writeFile(filePath, newContent);
|
|
523
520
|
result.modifiedFiles.push(fileName);
|
|
524
521
|
}
|
|
525
522
|
catch (error) {
|
|
@@ -545,10 +542,10 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
545
542
|
if (!(await isGeneratedLegacyCommand(filePath))) {
|
|
546
543
|
continue;
|
|
547
544
|
}
|
|
548
|
-
await
|
|
545
|
+
await ops.removeFile(filePath);
|
|
549
546
|
deleted.push(name);
|
|
550
547
|
}
|
|
551
|
-
if (!(await settleLegacyCommandDir(projectPath, dirPath, result))) {
|
|
548
|
+
if (!(await settleLegacyCommandDir(projectPath, dirPath, result, ops))) {
|
|
552
549
|
result.deletedFiles.push(...deleted.map((name) => `${dirPath}/${name}`));
|
|
553
550
|
}
|
|
554
551
|
}
|
|
@@ -570,7 +567,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
570
567
|
continue;
|
|
571
568
|
}
|
|
572
569
|
}
|
|
573
|
-
await
|
|
570
|
+
await ops.removeFile(fullPath);
|
|
574
571
|
result.deletedFiles.push(filePath);
|
|
575
572
|
}
|
|
576
573
|
catch (error) {
|
|
@@ -581,7 +578,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
581
578
|
// file by file above; record what was left in it.
|
|
582
579
|
for (const dirPath of partlyCleanedDirs) {
|
|
583
580
|
try {
|
|
584
|
-
await settleLegacyCommandDir(projectPath, dirPath, result);
|
|
581
|
+
await settleLegacyCommandDir(projectPath, dirPath, result, ops);
|
|
585
582
|
}
|
|
586
583
|
catch (error) {
|
|
587
584
|
result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
|
|
@@ -595,7 +592,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
595
592
|
continue;
|
|
596
593
|
}
|
|
597
594
|
try {
|
|
598
|
-
await
|
|
595
|
+
await ops.removeFile(filePath);
|
|
599
596
|
result.deletedFiles.push(filePath);
|
|
600
597
|
const promptMatch = globalPromptMatchesByPath.get(filePath);
|
|
601
598
|
if (promptMatch?.replacementLabel) {
|
|
@@ -611,7 +608,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
611
608
|
const agentsPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'AGENTS.md');
|
|
612
609
|
if (await FileSystemUtils.fileExists(agentsPath)) {
|
|
613
610
|
try {
|
|
614
|
-
await
|
|
611
|
+
await ops.removeFile(agentsPath);
|
|
615
612
|
result.deletedFiles.push('openspec/AGENTS.md');
|
|
616
613
|
}
|
|
617
614
|
catch (error) {
|
package/dist/core/migration.d.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* Called by both init and update commands before profile resolution.
|
|
6
6
|
*/
|
|
7
7
|
import { type AIToolOption } from './config.js';
|
|
8
|
+
import { type Delivery, type GlobalConfig, type Profile } from './global-config.js';
|
|
9
|
+
import { type FileView } from './file-view.js';
|
|
8
10
|
export interface LegacyToolRoot {
|
|
9
11
|
/** Former tool root, e.g. '.kimi' */
|
|
10
12
|
root: string;
|
|
@@ -41,11 +43,27 @@ export interface LegacyToolMigration {
|
|
|
41
43
|
/** Whether this move needs the user's consent first */
|
|
42
44
|
needsConsent: boolean;
|
|
43
45
|
}
|
|
46
|
+
/** One file a legacy-root migration would move or drop. */
|
|
47
|
+
export interface LegacyMigrationFileOp {
|
|
48
|
+
toolId: string;
|
|
49
|
+
source: string;
|
|
50
|
+
destination: string;
|
|
51
|
+
disposition: 'move' | 'drop';
|
|
52
|
+
/** Whether an interactive run asks before this move */
|
|
53
|
+
needsConsent: boolean;
|
|
54
|
+
}
|
|
44
55
|
/**
|
|
45
56
|
* Reports the OpenSpec content sitting under each tool's legacy root, without
|
|
46
57
|
* moving anything. Callers use this to ask before a move that needs consent.
|
|
47
58
|
*/
|
|
48
59
|
export declare function findLegacyToolMigrations(projectPath: string, timing?: 'before-generation' | 'after-generation'): LegacyToolMigration[];
|
|
60
|
+
/**
|
|
61
|
+
* Lists, file by file, what `migrateLegacyToolDirs` would move or drop, without
|
|
62
|
+
* touching anything. Consent-gated roots are included: a non-interactive run
|
|
63
|
+
* moves them without asking. `view` decides what already sits at each
|
|
64
|
+
* destination, so a dry run can plan against files it has not written.
|
|
65
|
+
*/
|
|
66
|
+
export declare function planLegacyToolMigrations(projectPath: string, timing: 'before-generation' | 'after-generation', toolIds: string[] | undefined, view: FileView): LegacyMigrationFileOp[];
|
|
49
67
|
/**
|
|
50
68
|
* Moves OpenSpec-managed skill directories (openspec-*) and command files
|
|
51
69
|
* (opsx-*) from a tool's legacy root to its current one. When the destination
|
|
@@ -81,7 +99,7 @@ export declare function legacyMigrationNotice(migration: LegacyToolMigration): s
|
|
|
81
99
|
* Scans installed workflow files across all detected tools and returns
|
|
82
100
|
* the union of installed workflow IDs.
|
|
83
101
|
*/
|
|
84
|
-
export declare function scanInstalledWorkflows(projectPath: string, tools: AIToolOption[]): string[];
|
|
102
|
+
export declare function scanInstalledWorkflows(projectPath: string, tools: AIToolOption[], view?: FileView): string[];
|
|
85
103
|
/**
|
|
86
104
|
* Performs one-time migration if the global config does not yet have a profile field.
|
|
87
105
|
* Called by both init and update before profile resolution.
|
|
@@ -91,5 +109,13 @@ export declare function scanInstalledWorkflows(projectPath: string, tools: AIToo
|
|
|
91
109
|
* - If no profile field exists and no workflows are installed: no-op (defaults apply).
|
|
92
110
|
* - If profile field already exists: no-op.
|
|
93
111
|
*/
|
|
112
|
+
export declare function computeProfileMigration(projectPath: string, tools: AIToolOption[], view?: FileView): ProfileMigration | null;
|
|
113
|
+
export type ProfileMigration = {
|
|
114
|
+
profile: Profile;
|
|
115
|
+
workflows: string[];
|
|
116
|
+
delivery?: Delivery;
|
|
117
|
+
};
|
|
118
|
+
/** The global config the one-time profile migration saves. */
|
|
119
|
+
export declare function applyProfileMigration(migration: ProfileMigration): GlobalConfig;
|
|
94
120
|
export declare function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): void;
|
|
95
121
|
//# sourceMappingURL=migration.d.ts.map
|
package/dist/core/migration.js
CHANGED
|
@@ -14,6 +14,7 @@ import { ALL_WORKFLOWS } from './profiles.js';
|
|
|
14
14
|
import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js';
|
|
15
15
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
16
16
|
import { isSharedSkillTargetActive } from './shared-skill-target.js';
|
|
17
|
+
import { DISK_VIEW } from './file-view.js';
|
|
17
18
|
import { isLegacyCodexSkillEquivalentToCurrent } from './shared/skill-content-equivalence.js';
|
|
18
19
|
import path from 'path';
|
|
19
20
|
import * as fs from 'fs';
|
|
@@ -41,14 +42,14 @@ export const LEGACY_TOOL_ROOTS = {
|
|
|
41
42
|
// reported and kept rather than overwritten.
|
|
42
43
|
antigravity: [{ root: '.agent', needsConsent: false, timing: 'after-generation' }],
|
|
43
44
|
};
|
|
44
|
-
function classifyManagedFile(source, destination) {
|
|
45
|
+
function classifyManagedFile(source, destination, view = DISK_VIEW) {
|
|
45
46
|
if (isSamePath(source, destination))
|
|
46
47
|
return 'skip';
|
|
47
|
-
if (!
|
|
48
|
+
if (!view.exists(destination))
|
|
48
49
|
return 'move';
|
|
49
50
|
try {
|
|
50
51
|
const sourceContent = fs.readFileSync(source, 'utf-8');
|
|
51
|
-
const destinationContent =
|
|
52
|
+
const destinationContent = view.read(destination);
|
|
52
53
|
const equivalentGeneratedSkills = path.basename(source) === 'SKILL.md' &&
|
|
53
54
|
path.basename(destination) === 'SKILL.md' &&
|
|
54
55
|
isLegacyCodexSkillEquivalentToCurrent(sourceContent, destinationContent);
|
|
@@ -85,6 +86,17 @@ function legacyCommandPath(commandPath, currentRoot, legacyRoot) {
|
|
|
85
86
|
export function findLegacyToolMigrations(projectPath, timing = 'before-generation') {
|
|
86
87
|
return collectLegacyToolMigrations(projectPath, false, undefined, timing);
|
|
87
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Lists, file by file, what `migrateLegacyToolDirs` would move or drop, without
|
|
91
|
+
* touching anything. Consent-gated roots are included: a non-interactive run
|
|
92
|
+
* moves them without asking. `view` decides what already sits at each
|
|
93
|
+
* destination, so a dry run can plan against files it has not written.
|
|
94
|
+
*/
|
|
95
|
+
export function planLegacyToolMigrations(projectPath, timing, toolIds, view) {
|
|
96
|
+
const ops = [];
|
|
97
|
+
collectLegacyToolMigrations(projectPath, false, toolIds, timing, { view, ops });
|
|
98
|
+
return ops;
|
|
99
|
+
}
|
|
88
100
|
/**
|
|
89
101
|
* Moves OpenSpec-managed skill directories (openspec-*) and command files
|
|
90
102
|
* (opsx-*) from a tool's legacy root to its current one. When the destination
|
|
@@ -99,7 +111,7 @@ export function findLegacyToolMigrations(projectPath, timing = 'before-generatio
|
|
|
99
111
|
export function migrateLegacyToolDirs(projectPath, toolIds, timing = 'before-generation') {
|
|
100
112
|
return collectLegacyToolMigrations(projectPath, true, toolIds, timing);
|
|
101
113
|
}
|
|
102
|
-
function collectLegacyToolMigrations(projectPath, apply, toolIds, timing = 'before-generation') {
|
|
114
|
+
function collectLegacyToolMigrations(projectPath, apply, toolIds, timing = 'before-generation', plan) {
|
|
103
115
|
const migrations = [];
|
|
104
116
|
for (const tool of AI_TOOLS) {
|
|
105
117
|
if (!tool.skillsDir)
|
|
@@ -126,8 +138,11 @@ function collectLegacyToolMigrations(projectPath, apply, toolIds, timing = 'befo
|
|
|
126
138
|
console.warn(`Skipping legacy ${legacy.root}/ migration because the directory resolves outside this project.`);
|
|
127
139
|
continue;
|
|
128
140
|
}
|
|
129
|
-
const
|
|
130
|
-
|
|
141
|
+
const sink = plan
|
|
142
|
+
? { ...plan, toolId: tool.value, needsConsent: legacy.needsConsent }
|
|
143
|
+
: undefined;
|
|
144
|
+
const skills = migrateSkillDirs(projectPath, tool.skillsDir, legacy.root, apply, legacyTiming === 'after-generation', sink);
|
|
145
|
+
const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply, legacyTiming === 'after-generation', sink);
|
|
131
146
|
if (apply) {
|
|
132
147
|
removeDirIfEmpty(path.join(legacyRootPath, 'skills'));
|
|
133
148
|
removeDirIfEmpty(path.join(legacyRootPath, 'workflows'));
|
|
@@ -152,7 +167,8 @@ function collectLegacyToolMigrations(projectPath, apply, toolIds, timing = 'befo
|
|
|
152
167
|
}
|
|
153
168
|
return migrations;
|
|
154
169
|
}
|
|
155
|
-
function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDestination = false) {
|
|
170
|
+
function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDestination = false, sink) {
|
|
171
|
+
const view = sink?.view ?? DISK_VIEW;
|
|
156
172
|
const legacySkillsDir = path.join(projectPath, legacyRoot, 'skills');
|
|
157
173
|
if (!fs.existsSync(legacySkillsDir))
|
|
158
174
|
return { moved: 0, kept: 0 };
|
|
@@ -167,13 +183,13 @@ function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDe
|
|
|
167
183
|
continue;
|
|
168
184
|
const destination = path.join(currentSkillsDir, dirName);
|
|
169
185
|
const destinationSkill = path.join(destination, 'SKILL.md');
|
|
170
|
-
if (requireDestination && !
|
|
186
|
+
if (requireDestination && !view.exists(destinationSkill))
|
|
171
187
|
continue;
|
|
172
188
|
if (!areProjectArtifacts(projectPath, sourceSkill, destinationSkill)) {
|
|
173
189
|
console.warn(`Skipping legacy ${legacyRoot}/skills/${dirName} migration because it resolves outside this project.`);
|
|
174
190
|
continue;
|
|
175
191
|
}
|
|
176
|
-
const disposition = classifyManagedFile(sourceSkill, destinationSkill);
|
|
192
|
+
const disposition = classifyManagedFile(sourceSkill, destinationSkill, view);
|
|
177
193
|
if (disposition === 'skip')
|
|
178
194
|
continue;
|
|
179
195
|
if (disposition === 'keep') {
|
|
@@ -181,6 +197,13 @@ function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDe
|
|
|
181
197
|
continue;
|
|
182
198
|
}
|
|
183
199
|
if (!apply) {
|
|
200
|
+
sink?.ops.push({
|
|
201
|
+
toolId: sink.toolId,
|
|
202
|
+
source: sourceSkill,
|
|
203
|
+
destination: destinationSkill,
|
|
204
|
+
disposition,
|
|
205
|
+
needsConsent: sink.needsConsent,
|
|
206
|
+
});
|
|
184
207
|
moved++;
|
|
185
208
|
continue;
|
|
186
209
|
}
|
|
@@ -207,7 +230,8 @@ function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDe
|
|
|
207
230
|
}
|
|
208
231
|
return { moved, kept };
|
|
209
232
|
}
|
|
210
|
-
function migrateCommandFiles(projectPath, tool, legacyRoot, apply, requireDestination = false) {
|
|
233
|
+
function migrateCommandFiles(projectPath, tool, legacyRoot, apply, requireDestination = false, sink) {
|
|
234
|
+
const view = sink?.view ?? DISK_VIEW;
|
|
211
235
|
const adapter = CommandAdapterRegistry.get(tool.value);
|
|
212
236
|
if (!adapter || !tool.skillsDir)
|
|
213
237
|
return { moved: 0, kept: 0 };
|
|
@@ -226,13 +250,13 @@ function migrateCommandFiles(projectPath, tool, legacyRoot, apply, requireDestin
|
|
|
226
250
|
// No replacement means this command is not one OpenSpec installs now — a
|
|
227
251
|
// skills-only delivery or a deselected workflow — so relocating the legacy
|
|
228
252
|
// file would resurrect it under the current root.
|
|
229
|
-
if (requireDestination && !
|
|
253
|
+
if (requireDestination && !view.exists(destination))
|
|
230
254
|
continue;
|
|
231
255
|
if (!areProjectArtifacts(projectPath, source, destination)) {
|
|
232
256
|
console.warn(`Skipping legacy ${legacyPath} migration because it resolves outside this project.`);
|
|
233
257
|
continue;
|
|
234
258
|
}
|
|
235
|
-
const disposition = classifyManagedFile(source, destination);
|
|
259
|
+
const disposition = classifyManagedFile(source, destination, view);
|
|
236
260
|
if (disposition === 'skip')
|
|
237
261
|
continue;
|
|
238
262
|
if (disposition === 'keep') {
|
|
@@ -240,6 +264,13 @@ function migrateCommandFiles(projectPath, tool, legacyRoot, apply, requireDestin
|
|
|
240
264
|
continue;
|
|
241
265
|
}
|
|
242
266
|
if (!apply) {
|
|
267
|
+
sink?.ops.push({
|
|
268
|
+
toolId: sink.toolId,
|
|
269
|
+
source,
|
|
270
|
+
destination,
|
|
271
|
+
disposition,
|
|
272
|
+
needsConsent: sink.needsConsent,
|
|
273
|
+
});
|
|
243
274
|
moved++;
|
|
244
275
|
continue;
|
|
245
276
|
}
|
|
@@ -344,7 +375,7 @@ function removeDirIfEmpty(dirPath) {
|
|
|
344
375
|
// Missing or non-empty directory — nothing to do
|
|
345
376
|
}
|
|
346
377
|
}
|
|
347
|
-
function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots = false) {
|
|
378
|
+
function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots = false, view = DISK_VIEW) {
|
|
348
379
|
const installed = new Set();
|
|
349
380
|
let hasSkills = false;
|
|
350
381
|
let hasCommands = false;
|
|
@@ -355,7 +386,7 @@ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots =
|
|
|
355
386
|
if (tool.globalSkillsDir) {
|
|
356
387
|
skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
|
|
357
388
|
}
|
|
358
|
-
else if (isSharedSkillTargetActive(projectPath, tool.value)) {
|
|
389
|
+
else if (isSharedSkillTargetActive(projectPath, tool.value, view)) {
|
|
359
390
|
skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
|
|
360
391
|
if (includeLegacyRoots) {
|
|
361
392
|
skillsDirs.push(...(tool.legacySkillsDirs ?? []).map((root) => path.join(projectPath, root, 'skills')));
|
|
@@ -365,7 +396,7 @@ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots =
|
|
|
365
396
|
for (const workflowId of ALL_WORKFLOWS) {
|
|
366
397
|
const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId];
|
|
367
398
|
const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md');
|
|
368
|
-
if (
|
|
399
|
+
if (view.exists(skillFile)) {
|
|
369
400
|
installed.add(workflowId);
|
|
370
401
|
hasSkills = true;
|
|
371
402
|
}
|
|
@@ -395,7 +426,7 @@ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots =
|
|
|
395
426
|
const fullPath = path.isAbsolute(candidate)
|
|
396
427
|
? candidate
|
|
397
428
|
: path.join(projectPath, candidate);
|
|
398
|
-
if (
|
|
429
|
+
if (view.exists(fullPath)) {
|
|
399
430
|
installed.add(workflowId);
|
|
400
431
|
hasCommands = true;
|
|
401
432
|
break;
|
|
@@ -413,8 +444,8 @@ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots =
|
|
|
413
444
|
* Scans installed workflow files across all detected tools and returns
|
|
414
445
|
* the union of installed workflow IDs.
|
|
415
446
|
*/
|
|
416
|
-
export function scanInstalledWorkflows(projectPath, tools) {
|
|
417
|
-
return scanInstalledWorkflowArtifacts(projectPath, tools).workflows;
|
|
447
|
+
export function scanInstalledWorkflows(projectPath, tools, view = DISK_VIEW) {
|
|
448
|
+
return scanInstalledWorkflowArtifacts(projectPath, tools, false, view).workflows;
|
|
418
449
|
}
|
|
419
450
|
function inferDelivery(artifacts) {
|
|
420
451
|
if (artifacts.hasSkills && artifacts.hasCommands) {
|
|
@@ -434,13 +465,12 @@ function inferDelivery(artifacts) {
|
|
|
434
465
|
* - If no profile field exists and no workflows are installed: no-op (defaults apply).
|
|
435
466
|
* - If profile field already exists: no-op.
|
|
436
467
|
*/
|
|
437
|
-
export function
|
|
468
|
+
export function computeProfileMigration(projectPath, tools, view = DISK_VIEW) {
|
|
438
469
|
// A config that cannot be parsed, or is not a JSON object, is never saved
|
|
439
470
|
// over; skip migration rather than fail init or update on it.
|
|
440
471
|
if (isGlobalConfigUnreadable()) {
|
|
441
|
-
return;
|
|
472
|
+
return null;
|
|
442
473
|
}
|
|
443
|
-
const config = getGlobalConfig();
|
|
444
474
|
// Check raw config file for profile field presence
|
|
445
475
|
const configPath = getGlobalConfigPath();
|
|
446
476
|
let rawConfig = {};
|
|
@@ -450,25 +480,43 @@ export function migrateIfNeeded(projectPath, tools) {
|
|
|
450
480
|
}
|
|
451
481
|
}
|
|
452
482
|
catch {
|
|
453
|
-
return; // Can't read config, skip migration
|
|
483
|
+
return null; // Can't read config, skip migration
|
|
454
484
|
}
|
|
455
485
|
// If profile is already explicitly set, no migration needed
|
|
456
486
|
if (rawConfig.profile !== undefined) {
|
|
457
|
-
return;
|
|
487
|
+
return null;
|
|
458
488
|
}
|
|
459
489
|
// Scan for installed workflows
|
|
460
|
-
const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools, true);
|
|
490
|
+
const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools, true, view);
|
|
461
491
|
const installedWorkflows = artifacts.workflows;
|
|
462
492
|
if (installedWorkflows.length === 0) {
|
|
463
493
|
// No workflows installed, new user — defaults will apply
|
|
464
|
-
return;
|
|
494
|
+
return null;
|
|
465
495
|
}
|
|
496
|
+
return {
|
|
497
|
+
profile: 'custom',
|
|
498
|
+
workflows: installedWorkflows,
|
|
499
|
+
...(rawConfig.delivery === undefined ? { delivery: inferDelivery(artifacts) } : {}),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/** The global config the one-time profile migration saves. */
|
|
503
|
+
export function applyProfileMigration(migration) {
|
|
504
|
+
const config = getGlobalConfig();
|
|
466
505
|
// Migrate: set profile to custom with detected workflows
|
|
467
|
-
config.profile =
|
|
468
|
-
config.workflows =
|
|
469
|
-
if (
|
|
470
|
-
config.delivery =
|
|
506
|
+
config.profile = migration.profile;
|
|
507
|
+
config.workflows = migration.workflows;
|
|
508
|
+
if (migration.delivery !== undefined) {
|
|
509
|
+
config.delivery = migration.delivery;
|
|
510
|
+
}
|
|
511
|
+
return config;
|
|
512
|
+
}
|
|
513
|
+
export function migrateIfNeeded(projectPath, tools) {
|
|
514
|
+
const migration = computeProfileMigration(projectPath, tools);
|
|
515
|
+
if (!migration) {
|
|
516
|
+
return;
|
|
471
517
|
}
|
|
518
|
+
const config = applyProfileMigration(migration);
|
|
519
|
+
const installedWorkflows = migration.workflows;
|
|
472
520
|
saveGlobalConfig(config);
|
|
473
521
|
console.log(`Migrated: custom profile with ${installedWorkflows.length} workflows`);
|
|
474
522
|
// Each detected tool resolves to a propose reference for its surface: the
|
|
@@ -8,10 +8,18 @@
|
|
|
8
8
|
* requirements during validation and archiving.
|
|
9
9
|
*/
|
|
10
10
|
function getFenceMarker(line) {
|
|
11
|
-
|
|
11
|
+
// Unanchored at the end: `.` does not match `\r`, so `(.*)$` would reject
|
|
12
|
+
// every opener on a line that still carries a CRLF ending.
|
|
13
|
+
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})(.*)/);
|
|
12
14
|
if (!fenceMatch) {
|
|
13
15
|
return null;
|
|
14
16
|
}
|
|
17
|
+
// CommonMark: a backtick fence's info string may not contain a backtick, so
|
|
18
|
+
// a line like ```npm test``` is inline code, not a fence opener. Treating it
|
|
19
|
+
// as one masked every following line, hiding later requirements.
|
|
20
|
+
if (fenceMatch[1][0] === '`' && fenceMatch[2].includes('`')) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
15
23
|
return {
|
|
16
24
|
marker: fenceMatch[1][0],
|
|
17
25
|
length: fenceMatch[1].length,
|
|
@@ -106,6 +106,16 @@ export interface DeltaPlan {
|
|
|
106
106
|
*/
|
|
107
107
|
export declare function parseDeltaSpec(content: string): DeltaPlan;
|
|
108
108
|
export declare function describeUnappliedContent(content: UnappliedContent, sentenceStart?: boolean): string;
|
|
109
|
+
/**
|
|
110
|
+
* One `#### ` child of a requirement block: its rendered name and its lines,
|
|
111
|
+
* header included. Exported because the validator's scenario-step check walks
|
|
112
|
+
* the same blocks the loss comparison does — a second reader of the same
|
|
113
|
+
* format is how the two drifted apart before.
|
|
114
|
+
*/
|
|
115
|
+
export interface ScenarioBlock {
|
|
116
|
+
name: string;
|
|
117
|
+
raw: string;
|
|
118
|
+
}
|
|
109
119
|
/** Both directions of the scenario-name comparison, plus the two totals. */
|
|
110
120
|
export interface ScenarioNameDiff {
|
|
111
121
|
/** Names the current block has that the incoming block does not cover. */
|
|
@@ -153,4 +163,24 @@ export declare function describeScenarioBalance(diff: ScenarioNameDiff): string;
|
|
|
153
163
|
* exactly as the MODIFIED loss check does.
|
|
154
164
|
*/
|
|
155
165
|
export declare function scenarioNameFromHeaderText(headerText: string): string;
|
|
166
|
+
/**
|
|
167
|
+
* Whether a `#### ` header line carries the canonical `Scenario:` label. Any
|
|
168
|
+
* level-4 child counts as a scenario for counting and loss purposes (the
|
|
169
|
+
* parity the SCENARIO_HEADER comment protects), but the step check warns only
|
|
170
|
+
* about blocks that claim to be scenarios: a `#### Notes` child is prose on
|
|
171
|
+
* purpose, and a missing WHEN/THEN there is noise, not a finding.
|
|
172
|
+
*/
|
|
173
|
+
export declare function isScenarioLabeledHeader(headerLine: string): boolean;
|
|
174
|
+
/**
|
|
175
|
+
* Every `#### ` child of a requirement block, header line included in `raw`,
|
|
176
|
+
* in document order. A scenario is ANY non-fenced `#### ` header, matching the
|
|
177
|
+
* spec path's SCENARIO_HEADER / countScenarios (requirement-text.ts) exactly —
|
|
178
|
+
* not only `#### Scenario:`. The two MUST agree: a level-4 child whose header
|
|
179
|
+
* is not literally `Scenario:` (e.g. `#### Edge case`) is still a scenario the
|
|
180
|
+
* spec path counts, so a MODIFIED block that drops it would otherwise slip past
|
|
181
|
+
* the loss check and be deleted by archive with no error (the parity the
|
|
182
|
+
* SCENARIO_HEADER comment warns not to break). A `####` inside a fenced example
|
|
183
|
+
* is masked out, matching countScenarios.
|
|
184
|
+
*/
|
|
185
|
+
export declare function parseScenarioBlocks(requirementRaw: string): ScenarioBlock[];
|
|
156
186
|
//# sourceMappingURL=requirement-blocks.d.ts.map
|
|
@@ -547,16 +547,29 @@ export function scenarioNameFromHeaderText(headerText) {
|
|
|
547
547
|
.replace(/^Scenario:\s*/i, '')
|
|
548
548
|
.trim();
|
|
549
549
|
}
|
|
550
|
-
|
|
550
|
+
/**
|
|
551
|
+
* Whether a `#### ` header line carries the canonical `Scenario:` label. Any
|
|
552
|
+
* level-4 child counts as a scenario for counting and loss purposes (the
|
|
553
|
+
* parity the SCENARIO_HEADER comment protects), but the step check warns only
|
|
554
|
+
* about blocks that claim to be scenarios: a `#### Notes` child is prose on
|
|
555
|
+
* purpose, and a missing WHEN/THEN there is noise, not a finding.
|
|
556
|
+
*/
|
|
557
|
+
export function isScenarioLabeledHeader(headerLine) {
|
|
558
|
+
return /^Scenario:\s*/i.test(headerLine.replace(SCENARIO_HEADER, ''));
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Every `#### ` child of a requirement block, header line included in `raw`,
|
|
562
|
+
* in document order. A scenario is ANY non-fenced `#### ` header, matching the
|
|
563
|
+
* spec path's SCENARIO_HEADER / countScenarios (requirement-text.ts) exactly —
|
|
564
|
+
* not only `#### Scenario:`. The two MUST agree: a level-4 child whose header
|
|
565
|
+
* is not literally `Scenario:` (e.g. `#### Edge case`) is still a scenario the
|
|
566
|
+
* spec path counts, so a MODIFIED block that drops it would otherwise slip past
|
|
567
|
+
* the loss check and be deleted by archive with no error (the parity the
|
|
568
|
+
* SCENARIO_HEADER comment warns not to break). A `####` inside a fenced example
|
|
569
|
+
* is masked out, matching countScenarios.
|
|
570
|
+
*/
|
|
571
|
+
export function parseScenarioBlocks(requirementRaw) {
|
|
551
572
|
const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n');
|
|
552
|
-
// A scenario is ANY non-fenced `#### ` header, matching the spec path's
|
|
553
|
-
// SCENARIO_HEADER / countScenarios (requirement-text.ts) exactly — not only
|
|
554
|
-
// `#### Scenario:`. The two MUST agree: a level-4 child whose header is not
|
|
555
|
-
// literally `Scenario:` (e.g. `#### Edge case`) is still a scenario the spec
|
|
556
|
-
// path counts, so a MODIFIED block that drops it would otherwise slip past
|
|
557
|
-
// this loss check and be deleted by archive with no error (the parity the
|
|
558
|
-
// SCENARIO_HEADER comment warns not to break). A `####` inside a fenced
|
|
559
|
-
// example is masked out, matching countScenarios.
|
|
560
573
|
const mask = buildCodeFenceMask(lines);
|
|
561
574
|
const scenarios = [];
|
|
562
575
|
let index = 0;
|
|
@@ -62,6 +62,20 @@ export declare function extractRequirementText(headerTitle: string, bodyLines: s
|
|
|
62
62
|
* example is not a real scenario and is not counted.
|
|
63
63
|
*/
|
|
64
64
|
export declare function countScenarios(bodyLines: string[]): number;
|
|
65
|
+
/** The two halves of a scenario, as carried by its non-fenced body lines. */
|
|
66
|
+
export interface ScenarioStepCoverage {
|
|
67
|
+
hasTrigger: boolean;
|
|
68
|
+
hasOutcome: boolean;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Whether a scenario block (header line included, as `parseScenarioBlocks`
|
|
72
|
+
* returns it) carries a trigger step (**WHEN** or **GIVEN**) and an outcome
|
|
73
|
+
* step (**THEN**) outside fenced examples. `AND` only continues a step that is
|
|
74
|
+
* already there, so neither half can be carried by an `AND` line; a keyword
|
|
75
|
+
* inside a fenced block is documentation, not a step — the same masking
|
|
76
|
+
* `extractRequirementBody` applies to requirement text.
|
|
77
|
+
*/
|
|
78
|
+
export declare function scenarioStepCoverage(scenarioRaw: string): ScenarioStepCoverage;
|
|
65
79
|
/** Count the `#### ` headers in a requirement block that have no body under them. */
|
|
66
80
|
export declare function countEmptyScenarios(bodyLines: string[]): number;
|
|
67
81
|
//# sourceMappingURL=requirement-text.d.ts.map
|
|
@@ -101,6 +101,45 @@ export function extractRequirementText(headerTitle, bodyLines) {
|
|
|
101
101
|
export function countScenarios(bodyLines) {
|
|
102
102
|
return readScenarioBodies(bodyLines).filter(hasScenarioBody).length;
|
|
103
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* A step keyword as it is actually written: bolded in any case, with or
|
|
106
|
+
* without a colon inside the bold (`**WHEN**`, `**Given:**`), or bare and
|
|
107
|
+
* uppercase (`WHEN`). Bold carries the step-ness, so its case can be liberal;
|
|
108
|
+
* a bare word must be uppercase to count — lowercase `when`/`then` are
|
|
109
|
+
* ordinary English prose, and the missing-SHALL/MUST check is uppercase-only
|
|
110
|
+
* for the same reason. Bold is `**` or `__`; the bare pattern cannot catch
|
|
111
|
+
* `__WHEN__`, because `_` is a word character and `\b` never fires beside it.
|
|
112
|
+
*/
|
|
113
|
+
const BOLD_TRIGGER_STEP = /(?:\*\*|__)(?:WHEN|GIVEN):?(?:\*\*|__)/i;
|
|
114
|
+
const BARE_TRIGGER_STEP = /\b(?:WHEN|GIVEN)\b/;
|
|
115
|
+
const BOLD_OUTCOME_STEP = /(?:\*\*|__)THEN:?(?:\*\*|__)/i;
|
|
116
|
+
const BARE_OUTCOME_STEP = /\bTHEN\b/;
|
|
117
|
+
/**
|
|
118
|
+
* Whether a scenario block (header line included, as `parseScenarioBlocks`
|
|
119
|
+
* returns it) carries a trigger step (**WHEN** or **GIVEN**) and an outcome
|
|
120
|
+
* step (**THEN**) outside fenced examples. `AND` only continues a step that is
|
|
121
|
+
* already there, so neither half can be carried by an `AND` line; a keyword
|
|
122
|
+
* inside a fenced block is documentation, not a step — the same masking
|
|
123
|
+
* `extractRequirementBody` applies to requirement text.
|
|
124
|
+
*/
|
|
125
|
+
export function scenarioStepCoverage(scenarioRaw) {
|
|
126
|
+
const lines = scenarioRaw.replace(/\r\n?/g, '\n').split('\n');
|
|
127
|
+
const mask = buildCodeFenceMask(lines);
|
|
128
|
+
let hasTrigger = false;
|
|
129
|
+
let hasOutcome = false;
|
|
130
|
+
// Line 0 is the scenario's own header: a keyword in the title
|
|
131
|
+
// (`#### Scenario: What happens THEN`) is a name, not a step.
|
|
132
|
+
for (let i = 1; i < lines.length; i++) {
|
|
133
|
+
if (mask[i])
|
|
134
|
+
continue;
|
|
135
|
+
const line = lines[i];
|
|
136
|
+
if (BOLD_TRIGGER_STEP.test(line) || BARE_TRIGGER_STEP.test(line))
|
|
137
|
+
hasTrigger = true;
|
|
138
|
+
if (BOLD_OUTCOME_STEP.test(line) || BARE_OUTCOME_STEP.test(line))
|
|
139
|
+
hasOutcome = true;
|
|
140
|
+
}
|
|
141
|
+
return { hasTrigger, hasOutcome };
|
|
142
|
+
}
|
|
104
143
|
/** Count the `#### ` headers in a requirement block that have no body under them. */
|
|
105
144
|
export function countEmptyScenarios(bodyLines) {
|
|
106
145
|
return readScenarioBodies(bodyLines).filter((body) => !hasScenarioBody(body)).length;
|
|
@@ -3,7 +3,7 @@ import { normalizeRequirementName } from './requirement-blocks.js';
|
|
|
3
3
|
const REQUIREMENTS_SECTION_HEADER = /^##\s+Requirements\s*$/i;
|
|
4
4
|
const TOP_LEVEL_SECTION_HEADER = /^##\s+/;
|
|
5
5
|
const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i;
|
|
6
|
-
const REQUIREMENT_HEADER = /^###\s
|
|
6
|
+
const REQUIREMENT_HEADER = /^###\s*Requirement:\s*(.+)\s*$/i;
|
|
7
7
|
export function findMainSpecStructureIssues(content) {
|
|
8
8
|
const normalized = content.replace(/\r\n?/g, '\n');
|
|
9
9
|
const stripped = stripFencedCodeBlocksPreservingLines(normalized);
|