@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.
Files changed (68) hide show
  1. package/README.md +12 -10
  2. package/dist/cli/index.js +47 -0
  3. package/dist/commands/completion.js +4 -2
  4. package/dist/commands/feedback.js +33 -20
  5. package/dist/commands/install-standalone.d.ts +27 -0
  6. package/dist/commands/install-standalone.js +150 -0
  7. package/dist/commands/schema.js +10 -3
  8. package/dist/commands/validate.js +33 -3
  9. package/dist/commands/workflow/instructions.js +15 -4
  10. package/dist/commands/workflow/new-change.js +12 -4
  11. package/dist/commands/workflow/status.js +5 -2
  12. package/dist/commands/workflow/templates.js +5 -2
  13. package/dist/core/artifact-graph/resolver.d.ts +6 -0
  14. package/dist/core/artifact-graph/resolver.js +11 -0
  15. package/dist/core/available-tools.d.ts +2 -1
  16. package/dist/core/available-tools.js +8 -28
  17. package/dist/core/completion-tip.d.ts +23 -0
  18. package/dist/core/completion-tip.js +4 -4
  19. package/dist/core/completions/command-registry.js +15 -0
  20. package/dist/core/completions/installers/zsh-installer.d.ts +9 -1
  21. package/dist/core/completions/installers/zsh-installer.js +64 -19
  22. package/dist/core/config-schema.d.ts +1 -0
  23. package/dist/core/config-schema.js +1 -0
  24. package/dist/core/file-view.d.ts +13 -0
  25. package/dist/core/file-view.js +14 -0
  26. package/dist/core/github-copilot/cloud-agent.d.ts +10 -2
  27. package/dist/core/github-copilot/cloud-agent.js +13 -9
  28. package/dist/core/global-config.d.ts +4 -0
  29. package/dist/core/global-config.js +5 -1
  30. package/dist/core/id.d.ts +8 -0
  31. package/dist/core/id.js +15 -0
  32. package/dist/core/legacy-cleanup.d.ts +11 -1
  33. package/dist/core/legacy-cleanup.js +15 -18
  34. package/dist/core/migration.d.ts +27 -1
  35. package/dist/core/migration.js +77 -29
  36. package/dist/core/parsers/code-fence.js +9 -1
  37. package/dist/core/parsers/requirement-blocks.d.ts +30 -0
  38. package/dist/core/parsers/requirement-blocks.js +22 -9
  39. package/dist/core/parsers/requirement-text.d.ts +14 -0
  40. package/dist/core/parsers/requirement-text.js +39 -0
  41. package/dist/core/parsers/spec-structure.js +1 -1
  42. package/dist/core/profile-sync-drift.d.ts +4 -3
  43. package/dist/core/profile-sync-drift.js +19 -16
  44. package/dist/core/project-config.d.ts +10 -0
  45. package/dist/core/project-config.js +26 -0
  46. package/dist/core/root-selection.d.ts +6 -0
  47. package/dist/core/root-selection.js +37 -0
  48. package/dist/core/shared/tool-detection.d.ts +12 -4
  49. package/dist/core/shared/tool-detection.js +29 -27
  50. package/dist/core/shared-skill-target.d.ts +12 -5
  51. package/dist/core/shared-skill-target.js +34 -23
  52. package/dist/core/standalone-tip.d.ts +6 -0
  53. package/dist/core/standalone-tip.js +38 -0
  54. package/dist/core/store/git.d.ts +22 -1
  55. package/dist/core/store/git.js +59 -4
  56. package/dist/core/templates/workflows/verify-change.js +4 -4
  57. package/dist/core/update-plan.d.ts +127 -0
  58. package/dist/core/update-plan.js +390 -0
  59. package/dist/core/update.d.ts +45 -1
  60. package/dist/core/update.js +279 -137
  61. package/dist/core/validation/validator.d.ts +27 -2
  62. package/dist/core/validation/validator.js +58 -4
  63. package/dist/core/version-check.d.ts +8 -1
  64. package/dist/core/version-check.js +48 -5
  65. package/dist/utils/executable-path.d.ts +5 -0
  66. package/dist/utils/executable-path.js +44 -0
  67. package/package.json +5 -4
  68. 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
- export declare function cleanupLegacyArtifacts(projectPath: string, detection: LegacyDetectionResult): Promise<CleanupResult>;
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 fs.rmdir(fullPath);
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
- * Cleans up legacy OpenSpec artifacts from a project.
498
- * Preserves openspec/project.md (shows migration hint instead of deleting).
499
- *
500
- * @param projectPath - The root path of the project
501
- * @param detection - Detection result from detectLegacyArtifacts
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 FileSystemUtils.writeFile(filePath, newContent);
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 fs.unlink(filePath);
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 fs.unlink(fullPath);
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 fs.unlink(filePath);
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 fs.unlink(agentsPath);
611
+ await ops.removeFile(agentsPath);
615
612
  result.deletedFiles.push('openspec/AGENTS.md');
616
613
  }
617
614
  catch (error) {
@@ -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
@@ -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 (!fs.existsSync(destination))
48
+ if (!view.exists(destination))
48
49
  return 'move';
49
50
  try {
50
51
  const sourceContent = fs.readFileSync(source, 'utf-8');
51
- const destinationContent = fs.readFileSync(destination, 'utf-8');
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 skills = migrateSkillDirs(projectPath, tool.skillsDir, legacy.root, apply, legacyTiming === 'after-generation');
130
- const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply, legacyTiming === 'after-generation');
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 && !fs.existsSync(destinationSkill))
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 && !fs.existsSync(destination))
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 (fs.existsSync(skillFile)) {
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 (fs.existsSync(fullPath)) {
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 migrateIfNeeded(projectPath, tools) {
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 = 'custom';
468
- config.workflows = installedWorkflows;
469
- if (rawConfig.delivery === undefined) {
470
- config.delivery = inferDelivery(artifacts);
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
- const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
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
- function parseScenarioBlocks(requirementRaw) {
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+Requirement:\s*(.+)\s*$/i;
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);