@fission-ai/openspec 1.8.0 → 1.10.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 (76) hide show
  1. package/dist/cli/index.d.ts +32 -0
  2. package/dist/cli/index.js +80 -9
  3. package/dist/commands/change.js +1 -0
  4. package/dist/commands/config.d.ts +5 -0
  5. package/dist/commands/config.js +19 -4
  6. package/dist/commands/feedback.js +28 -7
  7. package/dist/commands/schema.js +193 -28
  8. package/dist/commands/validate.d.ts +23 -0
  9. package/dist/commands/validate.js +148 -4
  10. package/dist/commands/workflow/schemas.d.ts +2 -0
  11. package/dist/commands/workflow/schemas.js +9 -2
  12. package/dist/core/archive.js +74 -10
  13. package/dist/core/artifact-graph/instruction-loader.js +2 -7
  14. package/dist/core/artifact-graph/outputs.d.ts +4 -0
  15. package/dist/core/artifact-graph/outputs.js +7 -0
  16. package/dist/core/artifact-graph/resolver.d.ts +0 -12
  17. package/dist/core/artifact-graph/resolver.js +14 -0
  18. package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
  19. package/dist/core/command-generation/adapters/command-code.js +34 -0
  20. package/dist/core/command-generation/adapters/index.d.ts +1 -0
  21. package/dist/core/command-generation/adapters/index.js +1 -0
  22. package/dist/core/command-generation/adapters/opencode.d.ts +2 -1
  23. package/dist/core/command-generation/adapters/opencode.js +13 -2
  24. package/dist/core/command-generation/registry.js +2 -0
  25. package/dist/core/completion-tip.d.ts +15 -0
  26. package/dist/core/completion-tip.js +133 -0
  27. package/dist/core/completions/command-registry.js +10 -0
  28. package/dist/core/completions/factory.d.ts +11 -0
  29. package/dist/core/completions/installers/bash-installer.d.ts +7 -0
  30. package/dist/core/completions/installers/bash-installer.js +15 -0
  31. package/dist/core/completions/installers/fish-installer.d.ts +7 -0
  32. package/dist/core/completions/installers/fish-installer.js +15 -0
  33. package/dist/core/completions/installers/powershell-installer.d.ts +7 -0
  34. package/dist/core/completions/installers/powershell-installer.js +15 -0
  35. package/dist/core/config-prompts.js +19 -10
  36. package/dist/core/config-schema.d.ts +1 -0
  37. package/dist/core/config-schema.js +2 -0
  38. package/dist/core/config.d.ts +1 -0
  39. package/dist/core/config.js +17 -15
  40. package/dist/core/global-config.d.ts +2 -0
  41. package/dist/core/init.d.ts +5 -0
  42. package/dist/core/init.js +110 -14
  43. package/dist/core/legacy-cleanup.d.ts +10 -0
  44. package/dist/core/legacy-cleanup.js +48 -26
  45. package/dist/core/parsers/requirement-blocks.js +41 -9
  46. package/dist/core/parsers/requirement-text.d.ts +8 -0
  47. package/dist/core/parsers/requirement-text.js +4 -3
  48. package/dist/core/profiles.d.ts +1 -1
  49. package/dist/core/profiles.js +11 -2
  50. package/dist/core/root-selection.d.ts +1 -1
  51. package/dist/core/shared-skill-target.d.ts +15 -0
  52. package/dist/core/shared-skill-target.js +30 -0
  53. package/dist/core/specs-apply.js +11 -4
  54. package/dist/core/templates/workflows/apply-change.js +3 -0
  55. package/dist/core/templates/workflows/feedback.js +1 -0
  56. package/dist/core/templates/workflows/onboard.js +4 -4
  57. package/dist/core/templates/workflows/propose.js +2 -2
  58. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  59. package/dist/core/templates/workflows/store-selection.js +1 -1
  60. package/dist/core/templates/workflows/update-change.js +4 -4
  61. package/dist/core/update.js +49 -11
  62. package/dist/core/validation/task-numbering.d.ts +16 -0
  63. package/dist/core/validation/task-numbering.js +57 -0
  64. package/dist/core/validation/validator.d.ts +5 -2
  65. package/dist/core/validation/validator.js +57 -3
  66. package/dist/telemetry/index.d.ts +3 -1
  67. package/dist/telemetry/index.js +10 -3
  68. package/dist/utils/change-metadata.js +17 -13
  69. package/dist/utils/change-utils.js +5 -0
  70. package/dist/utils/interactive.d.ts +30 -1
  71. package/dist/utils/interactive.js +105 -2
  72. package/dist/utils/task-progress.d.ts +34 -1
  73. package/dist/utils/task-progress.js +52 -28
  74. package/package.json +3 -6
  75. package/schemas/spec-driven/schema.yaml +17 -7
  76. package/scripts/postinstall.js +0 -83
package/dist/core/init.js CHANGED
@@ -10,7 +10,7 @@ import ora from 'ora';
10
10
  import * as fs from 'fs';
11
11
  import { createRequire } from 'module';
12
12
  import { FileSystemUtils } from '../utils/file-system.js';
13
- import { classifyOpenSpecDir, storePointerProblem } from './project-config.js';
13
+ import { classifyOpenSpecDir, MAX_CONTEXT_SIZE, readProjectConfig, storePointerProblem, } from './project-config.js';
14
14
  import { findRepoPlanningRootSync } from './planning-home.js';
15
15
  import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js';
16
16
  import { AI_TOOLS, OPENSPEC_DIR_NAME, resolveToolIdAlias, } from './config.js';
@@ -33,6 +33,13 @@ const { version: OPENSPEC_VERSION } = require('../../package.json');
33
33
  // Constants
34
34
  // -----------------------------------------------------------------------------
35
35
  const DEFAULT_SCHEMA = 'spec-driven';
36
+ function formatLanguageContext(language) {
37
+ return [
38
+ `Language: ${language}`,
39
+ `All artifacts must be written in ${language}.`,
40
+ 'Keep OpenSpec structural headings and SHALL/MUST keywords in English.',
41
+ ].join('\n');
42
+ }
36
43
  const PROGRESS_SPINNER = {
37
44
  interval: 80,
38
45
  frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],
@@ -56,6 +63,7 @@ const WORKFLOW_TO_SKILL_DIR = {
56
63
  // -----------------------------------------------------------------------------
57
64
  export class InitCommand {
58
65
  toolsArg;
66
+ language;
59
67
  force;
60
68
  interactiveOption;
61
69
  profileOverride;
@@ -63,6 +71,7 @@ export class InitCommand {
63
71
  copilotCloudOption;
64
72
  constructor(options = {}) {
65
73
  this.toolsArg = options.tools;
74
+ this.language = this.normalizeLanguage(options.language);
66
75
  this.force = options.force ?? false;
67
76
  this.interactiveOption = options.interactive;
68
77
  this.profileOverride = options.profile;
@@ -97,6 +106,7 @@ export class InitCommand {
97
106
  }
98
107
  }
99
108
  }
109
+ await this.assertLanguageCanBeApplied(projectPath, openspecPath);
100
110
  // Check for legacy artifacts and handle cleanup
101
111
  const deferredLegacyCleanup = await this.handleLegacyCleanup(projectPath, extendMode);
102
112
  // Migrate OpenSpec-managed skills left in renamed tool directories
@@ -517,11 +527,31 @@ export class InitCommand {
517
527
  }
518
528
  validateTools(toolIds, toolStates, projectPath) {
519
529
  const validatedTools = [];
520
- const reconciledToolIds = toolIds.includes('codex') && toolIds.includes('agents')
521
- ? toolIds.filter((toolId) => toolId !== 'agents')
530
+ const sharedAgentsTargets = ['codex', 'zed', 'agents'];
531
+ const selectedSharedTargets = sharedAgentsTargets.filter((toolId) => toolIds.includes(toolId));
532
+ // A Codex-rendered tree already serves Zed. Keep it when Zed is added later
533
+ // so Codex users do not lose the `$openspec-*` references they require.
534
+ const preserveConfiguredCodex = selectedSharedTargets.includes('zed') &&
535
+ toolStates.get('codex')?.configured;
536
+ const sharedTargetCandidates = preserveConfiguredCodex
537
+ ? [...new Set([...selectedSharedTargets, 'codex'])]
538
+ : selectedSharedTargets;
539
+ const sharedTargetOwner = sharedTargetCandidates.includes('codex')
540
+ ? 'codex'
541
+ : selectedSharedTargets.includes('zed')
542
+ ? 'zed'
543
+ : selectedSharedTargets[0];
544
+ const firstSharedIndex = toolIds.findIndex((id) => sharedAgentsTargets.includes(id));
545
+ const reconciledToolIds = sharedTargetCandidates.length > 1
546
+ ? toolIds.flatMap((toolId, index) => {
547
+ if (!sharedAgentsTargets.includes(toolId))
548
+ return [toolId];
549
+ return index === firstSharedIndex && sharedTargetOwner ? [sharedTargetOwner] : [];
550
+ })
522
551
  : toolIds;
523
- if (reconciledToolIds.length !== toolIds.length) {
524
- console.log(chalk.dim('Codex and agents share .agents/skills; writing one tree with Codex and generic skill references.'));
552
+ if (reconciledToolIds.length !== toolIds.length ||
553
+ reconciledToolIds.some((toolId, index) => toolId !== toolIds[index])) {
554
+ console.log(chalk.dim(`Codex, Zed, and agents share .agents/skills; writing one tree for ${sharedTargetOwner}.`));
525
555
  }
526
556
  for (const toolId of reconciledToolIds) {
527
557
  const tool = AI_TOOLS.find((t) => t.value === toolId);
@@ -544,6 +574,7 @@ export class InitCommand {
544
574
  skillsRoot: isGlobalSkillTarget ? skillsPath : projectPath,
545
575
  isGlobalSkillTarget,
546
576
  wasConfigured: preState?.configured ?? false,
577
+ requiresIdeRestart: tool.requiresIdeRestart,
547
578
  });
548
579
  }
549
580
  return validatedTools;
@@ -698,6 +729,53 @@ export class InitCommand {
698
729
  // ═══════════════════════════════════════════════════════════
699
730
  // CONFIG FILE
700
731
  // ═══════════════════════════════════════════════════════════
732
+ normalizeLanguage(language) {
733
+ if (language === undefined)
734
+ return undefined;
735
+ const normalized = language.trim();
736
+ if (!normalized) {
737
+ throw new Error('The --language option requires a non-empty value.');
738
+ }
739
+ if (/\p{Cc}|\p{Bidi_Control}|[\u200B\u2028\u2029\uFEFF]/u.test(normalized)) {
740
+ throw new Error('The --language option must be a single line without control or invisible formatting characters.');
741
+ }
742
+ const serializedContext = `${formatLanguageContext(normalized)}\n`;
743
+ if (Buffer.byteLength(serializedContext, 'utf8') > MAX_CONTEXT_SIZE) {
744
+ throw new Error(`The --language option is too long for OpenSpec's ${MAX_CONTEXT_SIZE / 1024}KB project context limit.`);
745
+ }
746
+ return normalized;
747
+ }
748
+ languageContext() {
749
+ if (!this.language)
750
+ return undefined;
751
+ return formatLanguageContext(this.language);
752
+ }
753
+ async assertLanguageCanBeApplied(projectPath, openspecPath) {
754
+ const languageContext = this.languageContext();
755
+ if (!languageContext)
756
+ return;
757
+ const configPath = path.join(openspecPath, 'config.yaml');
758
+ const hasConfig = fs.existsSync(configPath) ||
759
+ fs.existsSync(path.join(openspecPath, 'config.yml'));
760
+ if (!hasConfig) {
761
+ try {
762
+ FileSystemUtils.assertProjectArtifactPath(projectPath, configPath);
763
+ }
764
+ catch (error) {
765
+ const reason = error instanceof Error ? `: ${error.message}` : '';
766
+ throw new Error(`Cannot create openspec/config.yaml for --language${reason}`);
767
+ }
768
+ if (!(await FileSystemUtils.canWriteFile(configPath))) {
769
+ throw new Error('Cannot create openspec/config.yaml for --language: the destination is not writable.');
770
+ }
771
+ return;
772
+ }
773
+ const existingContext = readProjectConfig(projectPath)?.context;
774
+ if (existingContext?.includes(languageContext))
775
+ return;
776
+ throw new Error('--language does not overwrite an existing OpenSpec config. ' +
777
+ 'Add the language instruction to its context field instead.');
778
+ }
701
779
  async createConfig(openspecPath, extendMode) {
702
780
  const configPath = path.join(openspecPath, 'config.yaml');
703
781
  const configYmlPath = path.join(openspecPath, 'config.yml');
@@ -707,12 +785,19 @@ export class InitCommand {
707
785
  return 'exists';
708
786
  }
709
787
  try {
710
- const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA });
788
+ const yamlContent = serializeConfig({
789
+ schema: DEFAULT_SCHEMA,
790
+ context: this.languageContext(),
791
+ });
711
792
  FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), configPath);
712
793
  await FileSystemUtils.writeFile(configPath, yamlContent);
713
794
  return 'created';
714
795
  }
715
- catch {
796
+ catch (error) {
797
+ if (this.language) {
798
+ const reason = error instanceof Error ? `: ${error.message}` : '';
799
+ throw new Error(`Failed to create openspec/config.yaml for --language${reason}`);
800
+ }
716
801
  return 'skipped';
717
802
  }
718
803
  }
@@ -930,14 +1015,25 @@ export class InitCommand {
930
1015
  console.log();
931
1016
  console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
932
1017
  console.log(`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`);
933
- // Restart instruction if any tools were configured and got a surface
934
- // (when nothing was generated there is nothing a restart would pick up);
935
- // only mention commands when commands were actually generated. Not "slash
936
- // commands": Amazon Q's generated files are prompt-library entries invoked
937
- // with @, so a restart line promising slash commands would be wrong for it.
938
- if ((results.createdTools.length > 0 || results.refreshedTools.length > 0) && (commandsGenerated || skillsGenerated)) {
1018
+ // Restart instruction only when at least one IDE/editor-resident tool
1019
+ // actually received a generated surface. Two conditions, coupled to the SAME
1020
+ // tool: (1) its commands/skills are loaded by a long-running editor process
1021
+ // (CLI tools pick the files up immediately, so a restart line would be wrong
1022
+ // for them — see #1067), and (2) a surface was actually generated for it
1023
+ // under the active delivery (an IDE tool that generated nothing has nothing a
1024
+ // restart would pick up, even if a co-configured CLI tool did generate).
1025
+ // Wording follows what the IDE tool itself generated, not the global
1026
+ // aggregate: it must not say "commands" when the IDE tool only got skills
1027
+ // while a co-configured CLI tool got commands. Not "slash commands" either:
1028
+ // Amazon Q's generated files are prompt-library entries invoked with @, so a
1029
+ // restart line promising slash commands would be wrong for it.
1030
+ const restartCommandsGenerated = successfulTools.some((tool) => tool.requiresIdeRestart &&
1031
+ shouldGenerateCommandsForTool(tool.value, activeDelivery));
1032
+ const restartSkillsGenerated = successfulTools.some((tool) => tool.requiresIdeRestart &&
1033
+ shouldGenerateSkillsForTool(tool.value, activeDelivery));
1034
+ if (restartCommandsGenerated || restartSkillsGenerated) {
939
1035
  console.log();
940
- console.log(chalk.white(commandsGenerated
1036
+ console.log(chalk.white(restartCommandsGenerated
941
1037
  ? 'Restart your IDE for the new commands to take effect.'
942
1038
  : 'Restart your IDE for the new skills to take effect.'));
943
1039
  }
@@ -206,6 +206,16 @@ export declare function getLegacyWorkflowIdsForTool(detection: LegacyDetectionRe
206
206
  * callers can safely perform the immediate, non-deferred cleanup pass.
207
207
  */
208
208
  export declare function omitGlobalLegacyPromptFiles(detection: LegacyDetectionResult): LegacyDetectionResult;
209
+ /**
210
+ * Returns a detection snapshot with the repo-local slash-command artifacts of
211
+ * the given tools removed. The legacy-upgrade path uses this to skip cleaning a
212
+ * tool's legacy files when its replacement was deliberately NOT written — e.g. a
213
+ * Codex upgrade suppressed because the shared `.agents` root is already owned by
214
+ * another tool. Deleting the legacy prompt without writing its replacement would
215
+ * violate the cleanup contract ("remove X because replacement Y now exists") and
216
+ * strip the tool's only OpenSpec integration.
217
+ */
218
+ export declare function omitToolLegacyArtifacts(detection: LegacyDetectionResult, toolIds: readonly string[]): LegacyDetectionResult;
209
219
  /**
210
220
  * Builds a detection snapshot containing only the selected global Codex prompt
211
221
  * matches for replacement-gated cleanup.
@@ -614,41 +614,42 @@ export function formatDeferredGlobalPromptSummary(detection) {
614
614
  */
615
615
  export function getToolsFromLegacyArtifacts(detection) {
616
616
  const tools = new Set();
617
- // Match directories to tool IDs
618
617
  for (const dir of detection.slashCommandDirs) {
619
- for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
620
- if (pattern.type === 'directory' && pattern.path === dir) {
621
- tools.add(toolId);
622
- break;
623
- }
624
- }
618
+ const toolId = legacyToolIdForDir(dir);
619
+ if (toolId)
620
+ tools.add(toolId);
625
621
  }
626
- // Match files to tool IDs using glob patterns
627
622
  for (const file of detection.slashCommandFiles) {
628
- // Normalize file path to use forward slashes for consistent matching (Windows compatibility)
629
- const normalizedFile = normalizePathForMatch(file);
630
- for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
631
- if (pattern.type === 'files' && pattern.pattern) {
632
- const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
633
- let matched = false;
634
- for (const p of patterns) {
635
- const regex = globToRegex(p);
636
- if (regex.test(normalizedFile)) {
637
- tools.add(toolId);
638
- matched = true;
639
- break;
640
- }
641
- }
642
- if (matched)
643
- break;
644
- }
645
- }
623
+ const toolId = legacyToolIdForFile(file);
624
+ if (toolId)
625
+ tools.add(toolId);
646
626
  }
647
627
  for (const prompt of getLegacyGlobalPromptMatches(detection)) {
648
628
  tools.add(prompt.toolId);
649
629
  }
650
630
  return Array.from(tools);
651
631
  }
632
+ /** The tool that owns a repo-local legacy slash-command directory, if any. */
633
+ function legacyToolIdForDir(dir) {
634
+ for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
635
+ if (pattern.type === 'directory' && pattern.path === dir)
636
+ return toolId;
637
+ }
638
+ return undefined;
639
+ }
640
+ /** The tool that owns a repo-local legacy slash-command file, if any. */
641
+ function legacyToolIdForFile(file) {
642
+ // Normalize to forward slashes so the glob patterns match on Windows too.
643
+ const normalizedFile = normalizePathForMatch(file);
644
+ for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
645
+ if (pattern.type !== 'files' || !pattern.pattern)
646
+ continue;
647
+ const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
648
+ if (patterns.some((p) => globToRegex(p).test(normalizedFile)))
649
+ return toolId;
650
+ }
651
+ return undefined;
652
+ }
652
653
  /**
653
654
  * Normalizes global Codex prompt matches so callers can rely on workflow-aware
654
655
  * metadata even when older detection results only carry file paths.
@@ -699,6 +700,27 @@ export function omitGlobalLegacyPromptFiles(detection) {
699
700
  nextDetection.hasLegacyArtifacts = hasLegacyArtifacts(nextDetection);
700
701
  return nextDetection;
701
702
  }
703
+ /**
704
+ * Returns a detection snapshot with the repo-local slash-command artifacts of
705
+ * the given tools removed. The legacy-upgrade path uses this to skip cleaning a
706
+ * tool's legacy files when its replacement was deliberately NOT written — e.g. a
707
+ * Codex upgrade suppressed because the shared `.agents` root is already owned by
708
+ * another tool. Deleting the legacy prompt without writing its replacement would
709
+ * violate the cleanup contract ("remove X because replacement Y now exists") and
710
+ * strip the tool's only OpenSpec integration.
711
+ */
712
+ export function omitToolLegacyArtifacts(detection, toolIds) {
713
+ if (toolIds.length === 0)
714
+ return detection;
715
+ const skip = new Set(toolIds);
716
+ const nextDetection = {
717
+ ...detection,
718
+ slashCommandDirs: detection.slashCommandDirs.filter((dir) => !skip.has(legacyToolIdForDir(dir) ?? '')),
719
+ slashCommandFiles: detection.slashCommandFiles.filter((file) => !skip.has(legacyToolIdForFile(file) ?? '')),
720
+ };
721
+ nextDetection.hasLegacyArtifacts = hasLegacyArtifacts(nextDetection);
722
+ return nextDetection;
723
+ }
702
724
  /**
703
725
  * Builds a detection snapshot containing only the selected global Codex prompt
704
726
  * matches for replacement-gated cleanup.
@@ -1,4 +1,4 @@
1
- import { buildCodeFenceMask } from './requirement-text.js';
1
+ import { buildCodeFenceMask, SCENARIO_HEADER } from './requirement-text.js';
2
2
  export function normalizeRequirementName(name) {
3
3
  return name.trim();
4
4
  }
@@ -289,25 +289,57 @@ export function findMissingCurrentScenarios(current, incoming) {
289
289
  }
290
290
  return missing;
291
291
  }
292
+ /**
293
+ * Any non-fenced level-4 header on the given (masked) line. Reuses the spec
294
+ * path's SCENARIO_HEADER so the two counters cannot drift apart.
295
+ */
296
+ function scenarioHeaderAt(lines, mask, index) {
297
+ return !mask[index] && SCENARIO_HEADER.test(lines[index]);
298
+ }
299
+ /**
300
+ * The scenario name for a `#### ` header, matching the label the author reads:
301
+ * the header text with the leading `####`, an optional CommonMark closing `#`
302
+ * run (`#### Foo ####` renders as `Foo`), and an optional `Scenario:` prefix
303
+ * stripped. Both the current and incoming blocks run through here, so the
304
+ * comparison in findMissingCurrentScenarios stays internally consistent
305
+ * regardless of label — and two headers that render to the same title (one
306
+ * ATX-closed, one not) are not mistaken for a dropped scenario.
307
+ */
308
+ function scenarioNameAt(line) {
309
+ return line
310
+ .replace(SCENARIO_HEADER, '')
311
+ // Optional ATX closing sequence. CommonMark only treats a trailing `#` run
312
+ // as a close when it is preceded by a space or tab — not any Unicode space —
313
+ // so this uses `[ \t]`, not `\s`. A looser `\s` could strip a `#` run after
314
+ // an exotic space (e.g. NBSP) that CommonMark keeps, folding two distinct
315
+ // scenario names into one and masking a real loss. `[ \t]` keeps the fold
316
+ // faithful to how the header actually renders.
317
+ .replace(/[ \t]+#+[ \t]*$/, '')
318
+ .replace(/^Scenario:\s*/i, '')
319
+ .trim();
320
+ }
292
321
  function parseScenarioBlocks(requirementRaw) {
293
322
  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).
323
+ // A scenario is ANY non-fenced `#### ` header, matching the spec path's
324
+ // SCENARIO_HEADER / countScenarios (requirement-text.ts) exactly — not only
325
+ // `#### Scenario:`. The two MUST agree: a level-4 child whose header is not
326
+ // literally `Scenario:` (e.g. `#### Edge case`) is still a scenario the spec
327
+ // path counts, so a MODIFIED block that drops it would otherwise slip past
328
+ // this loss check and be deleted by archive with no error (the parity the
329
+ // SCENARIO_HEADER comment warns not to break). A `####` inside a fenced
330
+ // example is masked out, matching countScenarios.
298
331
  const mask = buildCodeFenceMask(lines);
299
332
  const scenarios = [];
300
333
  let index = 0;
301
334
  while (index < lines.length) {
302
- const headerMatch = mask[index] ? null : lines[index].match(/^####\s*Scenario:\s*(.+)\s*$/);
303
- if (!headerMatch) {
335
+ if (!scenarioHeaderAt(lines, mask, index)) {
304
336
  index++;
305
337
  continue;
306
338
  }
307
339
  const start = index;
308
- const name = headerMatch[1].trim();
340
+ const name = scenarioNameAt(lines[index]);
309
341
  index++;
310
- while (index < lines.length && (mask[index] || !/^####\s*Scenario:\s*(.+)\s*$/.test(lines[index]))) {
342
+ while (index < lines.length && !scenarioHeaderAt(lines, mask, index)) {
311
343
  index++;
312
344
  }
313
345
  scenarios.push({
@@ -9,6 +9,14 @@
9
9
  * `validate <change>`, `validate <spec>`, and `archive`.
10
10
  */
11
11
  export { buildCodeFenceMask } from './code-fence.js';
12
+ /**
13
+ * A level-4 header. Deliberately matches ANY `####` header, not only
14
+ * `#### Scenario:` — the spec path treats every level-4 child of a requirement
15
+ * as a scenario, so the delta counter must too (parity). The delta/loss path
16
+ * reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
17
+ * keep both paths on it rather than reintroducing a separate `Scenario:` regex.
18
+ */
19
+ export declare const SCENARIO_HEADER: RegExp;
12
20
  /**
13
21
  * The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
14
22
  * as whole words so the change-delta reader and the schema-based reader accept
@@ -19,10 +19,11 @@ const HEADER_LINE = /^#{1,6}\s/;
19
19
  /**
20
20
  * A level-4 header. Deliberately matches ANY `####` header, not only
21
21
  * `#### Scenario:` — the spec path treats every level-4 child of a requirement
22
- * as a scenario, so the delta counter must too (parity). Don't tighten this to
23
- * `Scenario:` without changing both paths together.
22
+ * as a scenario, so the delta counter must too (parity). The delta/loss path
23
+ * reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
24
+ * keep both paths on it rather than reintroducing a separate `Scenario:` regex.
24
25
  */
25
- const SCENARIO_HEADER = /^####\s+/;
26
+ export const SCENARIO_HEADER = /^####\s+/;
26
27
  /**
27
28
  * The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
28
29
  * as whole words so the change-delta reader and the schema-based reader accept
@@ -20,7 +20,7 @@ export type CoreWorkflowId = (typeof CORE_WORKFLOWS)[number];
20
20
  * Resolves which workflows should be active for a given profile configuration.
21
21
  *
22
22
  * - 'core' profile always returns CORE_WORKFLOWS
23
- * - 'custom' profile returns the provided customWorkflows, or empty array if not provided
23
+ * - 'custom' profile returns the provided customWorkflows and required dependencies
24
24
  */
25
25
  export declare function getProfileWorkflows(profile: Profile, customWorkflows?: string[]): readonly string[];
26
26
  //# sourceMappingURL=profiles.d.ts.map
@@ -30,11 +30,20 @@ export const ALL_WORKFLOWS = [
30
30
  * Resolves which workflows should be active for a given profile configuration.
31
31
  *
32
32
  * - 'core' profile always returns CORE_WORKFLOWS
33
- * - 'custom' profile returns the provided customWorkflows, or empty array if not provided
33
+ * - 'custom' profile returns the provided customWorkflows and required dependencies
34
34
  */
35
35
  export function getProfileWorkflows(profile, customWorkflows) {
36
36
  if (profile === 'custom') {
37
- return customWorkflows ?? [];
37
+ const workflows = customWorkflows ?? [];
38
+ const syncDependentIndex = workflows.findIndex((workflow) => workflow === 'archive' || workflow === 'bulk-archive');
39
+ if (syncDependentIndex !== -1 && !workflows.includes('sync')) {
40
+ return [
41
+ ...workflows.slice(0, syncDependentIndex),
42
+ 'sync',
43
+ ...workflows.slice(syncDependentIndex),
44
+ ];
45
+ }
46
+ return workflows;
38
47
  }
39
48
  return CORE_WORKFLOWS;
40
49
  }
@@ -119,7 +119,7 @@ export declare function toPlanningHome(root: ResolvedOpenSpecRoot): PlanningHome
119
119
  export declare function resolveRootForCommand(selector: StoreSelectorOptions, output?: {
120
120
  json?: boolean;
121
121
  failurePayload?: Record<string, unknown>;
122
- /** Diagnostic commands inspect what exists; they never scaffold. */
122
+ /** Commands that require an existing root set this to false. */
123
123
  allowImplicitRoot?: boolean;
124
124
  }): Promise<ResolvedOpenSpecRoot | null>;
125
125
  //# sourceMappingURL=root-selection.d.ts.map
@@ -12,5 +12,20 @@ export declare function reconcileSharedSkillTargets(projectPath: string, tools:
12
12
  * Non-shared roots are always active.
13
13
  */
14
14
  export declare function isSharedSkillTargetActive(projectPath: string, toolId: string): boolean;
15
+ /**
16
+ * The tool that already owns `toolId`'s shared skills root, when a DIFFERENT
17
+ * one does. Returns the owner's tool id only when the root already carries an
18
+ * ownership signal (a marker or generated skills) AND reconciliation resolves
19
+ * it to another tool. An empty or unclaimed root returns undefined, so a
20
+ * genuine first-time legacy upgrade — e.g. a Codex-only user with no `.agents`
21
+ * yet — is never reported as owned.
22
+ */
23
+ export declare function sharedSkillRootOwner(projectPath: string, toolId: string): string | undefined;
24
+ /**
25
+ * Whether generating `toolId` into its shared skills root would clobber a tree
26
+ * a DIFFERENT tool already owns — the guard the legacy-upgrade path uses before
27
+ * writing skills. See {@link sharedSkillRootOwner} for the ownership rules.
28
+ */
29
+ export declare function sharedSkillRootOwnedByOther(projectPath: string, toolId: string): boolean;
15
30
  export declare function writeSharedSkillTarget(projectPath: string, toolId: string): void;
16
31
  //# sourceMappingURL=shared-skill-target.d.ts.map
@@ -141,6 +141,36 @@ export function isSharedSkillTargetActive(projectPath, toolId) {
141
141
  return reconcileSharedSkillTargets(projectPath, sharingRoot)
142
142
  .some((candidate) => candidate.value === toolId);
143
143
  }
144
+ /**
145
+ * The tool that already owns `toolId`'s shared skills root, when a DIFFERENT
146
+ * one does. Returns the owner's tool id only when the root already carries an
147
+ * ownership signal (a marker or generated skills) AND reconciliation resolves
148
+ * it to another tool. An empty or unclaimed root returns undefined, so a
149
+ * genuine first-time legacy upgrade — e.g. a Codex-only user with no `.agents`
150
+ * yet — is never reported as owned.
151
+ */
152
+ export function sharedSkillRootOwner(projectPath, toolId) {
153
+ const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
154
+ if (!tool?.skillsDir)
155
+ return undefined;
156
+ const sharingRoot = AI_TOOLS.filter((candidate) => candidate.skillsDir === tool.skillsDir);
157
+ if (sharingRoot.length < 2)
158
+ return undefined;
159
+ const hasOwnerSignal = readSharedSkillTarget(projectPath, tool.skillsDir) !== undefined ||
160
+ hasCurrentSkills(projectPath, tool.skillsDir);
161
+ if (!hasOwnerSignal)
162
+ return undefined;
163
+ const owner = reconcileSharedSkillTargets(projectPath, sharingRoot)[0]?.value;
164
+ return owner && owner !== toolId ? owner : undefined;
165
+ }
166
+ /**
167
+ * Whether generating `toolId` into its shared skills root would clobber a tree
168
+ * a DIFFERENT tool already owns — the guard the legacy-upgrade path uses before
169
+ * writing skills. See {@link sharedSkillRootOwner} for the ownership rules.
170
+ */
171
+ export function sharedSkillRootOwnedByOther(projectPath, toolId) {
172
+ return sharedSkillRootOwner(projectPath, toolId) !== undefined;
173
+ }
144
174
  export function writeSharedSkillTarget(projectPath, toolId) {
145
175
  const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
146
176
  if (!tool?.skillsDir)
@@ -404,10 +404,17 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
404
404
  .concat(keptOrder.map((b) => b.raw))
405
405
  .join('\n\n')
406
406
  .trimEnd();
407
- const rebuilt = [parts.before.trimEnd(), parts.headerLine, reqBody, parts.after]
408
- .filter((s, idx) => !(idx === 0 && s === ''))
409
- .join('\n')
410
- .replace(/\n{3,}/g, '\n\n');
407
+ // The blank lines surrounding `## Requirements` belong to no slice: `before`
408
+ // and `after` carry at most one trailing/leading '\n' by construction, and
409
+ // the body is trimEnd()ed. Joining the slices with a bare '\n' therefore
410
+ // glued the heading to the Purpose paragraph and the first requirement, so
411
+ // every archive rewrote a well-formatted spec into that shape. Separate
412
+ // non-empty slices with one blank line instead.
413
+ const rebuilt = [parts.before.trimEnd(), parts.headerLine, reqBody, parts.after.trim()]
414
+ .filter((s) => s !== '')
415
+ .join('\n\n')
416
+ .replace(/\n{3,}/g, '\n\n')
417
+ .trimEnd() + '\n';
411
418
  return {
412
419
  rebuilt,
413
420
  counts: {
@@ -99,6 +99,7 @@ ${STORE_SELECTION_GUIDANCE}
99
99
  **Pause if:**
100
100
  - Task is unclear → ask for clarification
101
101
  - Implementation reveals a design issue → suggest updating artifacts
102
+ - A task needs work beyond what the spec and tasks describe, or you are tempted to drop, narrow, defer, or accept exceptions to specified behavior to make it fit → surface the added scope and ask; do not absorb it silently
102
103
  - Error or blocker encountered → report and wait for guidance
103
104
  - User interrupts
104
105
 
@@ -169,6 +170,8 @@ What would you like to do?
169
170
  - Keep code changes minimal and scoped to each task
170
171
  - Update task checkbox immediately after completing each task
171
172
  - Pause on errors, blockers, or unclear requirements - don't guess
173
+ - When a task needs work beyond what the spec describes, surface the added scope and pause - never silently narrow, defer, or simplify away specified behavior
174
+ - Only mark a task \`- [x]\` when its specified behavior is fully implemented, not when it is partially done or deferred
172
175
  - Use contextFiles from CLI output, don't assume specific file names
173
176
  - Do not use context or operation guidance as proof that a task is complete
174
177
  - Apply relevant project context; report conflicts with controlling workflow inputs
@@ -39,6 +39,7 @@ export function getFeedbackSkillTemplate() {
39
39
  5. **Submit on confirmation**
40
40
  - Use the \`openspec feedback\` command to submit
41
41
  - Format: \`openspec feedback "title" --body "body content"\`
42
+ - The command preserves the title text in the issue body and shortens long GitHub issue titles
42
43
  - The command will automatically add metadata (version, platform, timestamp)
43
44
 
44
45
  **Example Draft**
@@ -374,12 +374,12 @@ Here are the implementation tasks:
374
374
 
375
375
  ## 1. [Category or file]
376
376
 
377
- - [ ] 1.1 [Specific task]
378
- - [ ] 1.2 [Specific task]
377
+ - [ ] 1.1 [Specific task] — verify: [test, command, observable behavior, or delivered artifact]
378
+ - [ ] 1.2 [Specific task] — verify: [test, command, observable behavior, or delivered artifact]
379
379
 
380
- ## 2. Verify
380
+ ## 2. Integration Verification
381
381
 
382
- - [ ] 2.1 [Verification step]
382
+ - [ ] 2.1 Verify [broader integration or system behavior] with [end-to-end test or observable result]
383
383
 
384
384
  ---
385
385
 
@@ -42,7 +42,7 @@ ${STORE_SELECTION_GUIDANCE}
42
42
 
43
43
  **Use a different schema only if the user:**
44
44
  - Explicitly requests a specific schema by name → use \`--schema <schema-name>\`
45
- - Asks to "show workflows" or asks "what workflows" exist → resolve the authoritative root by running \`openspec context --json\` from the current working directory. If the user explicitly selected a registered store, use \`openspec context --json --store "<store-id>"\`. Then run \`openspec schemas --json\` with its working directory set to the returned \`root.path\` and let them choose. This preserves roots selected by a local \`store:\` pointer or the global \`defaultStore\`; \`schemas\` does not accept \`--store\`. If context reports only \`no_openspec_root\`, run \`openspec schemas --json\` from the current working directory instead. Do not use this fallback for invalid or unavailable stores.
45
+ - Asks to "show workflows" or asks "what workflows" exist → resolve the authoritative root by running \`openspec context --json\` from the current working directory. If the user explicitly selected a registered store, use \`openspec context --json --store "<store-id>"\`. Then run \`openspec schemas --json\` with its working directory set to the returned \`root.path\` and let them choose. This preserves roots selected by a local \`store:\` pointer or the global \`defaultStore\`; when a registered store was explicitly selected, append \`--store "<store-id>"\` to \`openspec schemas --json\` as well. If context reports only \`no_openspec_root\`, run \`openspec schemas --json\` from the current working directory instead. Do not use this fallback for invalid or unavailable stores.
46
46
 
47
47
  Otherwise, omit \`--schema\` to preserve the configured default.
48
48
 
@@ -190,7 +190,7 @@ ${STORE_SELECTION_GUIDANCE}
190
190
 
191
191
  **Use a different schema only if the user:**
192
192
  - Explicitly requests a specific schema by name → use \`--schema <schema-name>\`
193
- - Asks to "show workflows" or asks "what workflows" exist → resolve the authoritative root by running \`openspec context --json\` from the current working directory. If the user explicitly selected a registered store, use \`openspec context --json --store "<store-id>"\`. Then run \`openspec schemas --json\` with its working directory set to the returned \`root.path\` and let them choose. This preserves roots selected by a local \`store:\` pointer or the global \`defaultStore\`; \`schemas\` does not accept \`--store\`. If context reports only \`no_openspec_root\`, run \`openspec schemas --json\` from the current working directory instead. Do not use this fallback for invalid or unavailable stores.
193
+ - Asks to "show workflows" or asks "what workflows" exist → resolve the authoritative root by running \`openspec context --json\` from the current working directory. If the user explicitly selected a registered store, use \`openspec context --json --store "<store-id>"\`. Then run \`openspec schemas --json\` with its working directory set to the returned \`root.path\` and let them choose. This preserves roots selected by a local \`store:\` pointer or the global \`defaultStore\`; when a registered store was explicitly selected, append \`--store "<store-id>"\` to \`openspec schemas --json\` as well. If context reports only \`no_openspec_root\`, run \`openspec schemas --json\` from the current working directory instead. Do not use this fallback for invalid or unavailable stores.
194
194
 
195
195
  Otherwise, omit \`--schema\` to preserve the configured default.
196
196
 
@@ -4,5 +4,5 @@
4
4
  * Interpolated into every workflow's instructions so generated skills
5
5
  * consistently teach how to target a registered store with `--store <id>`.
6
6
  */
7
- export declare const STORE_SELECTION_GUIDANCE = "**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change \"<name>\" --json --store \"<id>\"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.";
7
+ export declare const STORE_SELECTION_GUIDANCE = "**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change \"<name>\" --json --store \"<id>\"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.";
8
8
  //# sourceMappingURL=store-selection.d.ts.map
@@ -4,5 +4,5 @@
4
4
  * Interpolated into every workflow's instructions so generated skills
5
5
  * consistently teach how to target a registered store with `--store <id>`.
6
6
  */
7
- export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store <id>\` on the commands that read or write specs and changes (\`new change\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`, \`view\`). Once selected, treat \`--store <id>\` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run \`openspec status --change "<name>" --json --store "<id>"\`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root.`;
7
+ export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store <id>\` on the commands that read or write specs and changes (\`new change\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`, \`schemas\`, \`view\`). Once selected, treat \`--store <id>\` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run \`openspec status --change "<name>" --json --store "<id>"\`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root.`;
8
8
  //# sourceMappingURL=store-selection.js.map