@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
@@ -9,7 +9,7 @@ ${STORE_SELECTION_GUIDANCE}
9
9
 
10
10
  **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
11
11
 
12
- \`/opsx:continue\` is an expanded-profile workflow and may not be installed. Before suggesting it anywhere below, verify that it is available. If it is unavailable, \`openspec status --change "<name>" --json\` shows the next artifact and \`openspec instructions "<artifact-id>" --change "<name>" --json\` explains how to create it.
12
+ \`/opsx:continue\` is an optional workflow and may not be installed. Before suggesting it anywhere below, verify that it is available. If it is unavailable, \`openspec status --change "<name>" --json\` shows the next artifact and \`openspec instructions "<artifact-id>" --change "<name>" --json\` explains how to create it.
13
13
 
14
14
  **Steps**
15
15
 
@@ -81,7 +81,7 @@ After each invocation, show:
81
81
  - Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`.
82
82
  - Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is \`/opsx:continue\`'s job.
83
83
  - Confirm every edit with the user before writing.
84
- - If the request changes the change's *intent* rather than refining it, first verify whether the expanded-profile \`/opsx:new\` workflow is available. If it is, recommend starting fresh with \`/opsx:new\` (the "Update vs. Start Fresh" heuristic). If it is unavailable, ask for a distinct unused change name and recommend \`openspec new change "<new-change-name>"\` instead.`,
84
+ - If the request changes the change's *intent* rather than refining it, first verify whether the optional \`/opsx:new\` workflow is available. If it is, recommend starting fresh with \`/opsx:new\` (the "Update vs. Start Fresh" heuristic). If it is unavailable, ask for a distinct unused change name and recommend \`openspec new change "<new-change-name>"\` instead.`,
85
85
  license: 'MIT',
86
86
  compatibility: 'Requires openspec CLI.',
87
87
  metadata: { author: 'openspec', version: '1.0' },
@@ -99,7 +99,7 @@ ${STORE_SELECTION_GUIDANCE}
99
99
 
100
100
  **Input**: Optionally specify a change name after \`/opsx:update\` (e.g., \`/opsx:update add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
101
101
 
102
- \`/opsx:continue\` is an expanded-profile workflow and may not be installed. Before suggesting it anywhere below, verify that it is available. If it is unavailable, \`openspec status --change "<name>" --json\` shows the next artifact and \`openspec instructions "<artifact-id>" --change "<name>" --json\` explains how to create it.
102
+ \`/opsx:continue\` is an optional workflow and may not be installed. Before suggesting it anywhere below, verify that it is available. If it is unavailable, \`openspec status --change "<name>" --json\` shows the next artifact and \`openspec instructions "<artifact-id>" --change "<name>" --json\` explains how to create it.
103
103
 
104
104
  **Steps**
105
105
 
@@ -171,7 +171,7 @@ After each invocation, show:
171
171
  - Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`.
172
172
  - Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is \`/opsx:continue\`'s job.
173
173
  - Confirm every edit with the user before writing.
174
- - If the request changes the change's *intent* rather than refining it, first verify whether the expanded-profile \`/opsx:new\` workflow is available. If it is, recommend starting fresh with \`/opsx:new\` (the "Update vs. Start Fresh" heuristic). If it is unavailable, ask for a distinct unused change name and recommend \`openspec new change "<new-change-name>"\` instead.`
174
+ - If the request changes the change's *intent* rather than refining it, first verify whether the optional \`/opsx:new\` workflow is available. If it is, recommend starting fresh with \`/opsx:new\` (the "Update vs. Start Fresh" heuristic). If it is unavailable, ask for a distinct unused change name and recommend \`openspec new change "<new-change-name>"\` instead.`
175
175
  };
176
176
  }
177
177
  //# sourceMappingURL=update-change.js.map
@@ -14,7 +14,7 @@ import { getSkillReferenceTransformer, getTransformerForTool, transformToSkillRe
14
14
  import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
15
15
  import { generateCommands, CommandAdapterRegistry, } from './command-generation/index.js';
16
16
  import { getToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, getToolsWithSkillsDir, hasGlobalSkillTarget, resolveToolSkillsDir, toolSupportsSkills, } from './shared/index.js';
17
- import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatDeferredGlobalPromptSummary, formatCleanupSummary, formatDetectionSummary, getLegacyGlobalPromptMatches, getLegacyWorkflowIdsForTool, getToolsFromLegacyArtifacts, omitGlobalLegacyPromptFiles, pickGlobalLegacyPromptFiles, } from './legacy-cleanup.js';
17
+ import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatDeferredGlobalPromptSummary, formatCleanupSummary, formatDetectionSummary, getLegacyGlobalPromptMatches, getLegacyWorkflowIdsForTool, getToolsFromLegacyArtifacts, omitGlobalLegacyPromptFiles, omitToolLegacyArtifacts, pickGlobalLegacyPromptFiles, } from './legacy-cleanup.js';
18
18
  import { isInteractive } from '../utils/interactive.js';
19
19
  import { getGlobalConfig } from './global-config.js';
20
20
  import { getProfileWorkflows, ALL_WORKFLOWS, CORE_WORKFLOWS } from './profiles.js';
@@ -23,7 +23,7 @@ import { getAvailableTools } from './available-tools.js';
23
23
  import { WORKFLOW_TO_SKILL_DIR, getConfiguredToolsForProfileSync, getToolsNeedingProfileSync, } from './profile-sync-drift.js';
24
24
  import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, findLegacyToolMigrations, migrateLegacyToolDirs, describeLegacyMigration, legacyMigrationNotice, keptInPlaceNotice, hasMovableContent, } from './migration.js';
25
25
  import { resolveCommandSurfaceCapability, resolveCommandInvocation, shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js';
26
- import { writeSharedSkillTarget } from './shared-skill-target.js';
26
+ import { writeSharedSkillTarget, sharedSkillRootOwner } from './shared-skill-target.js';
27
27
  import { includesGitHubCopilot, writeCopilotCloudFiles, removeCopilotCloudFiles, isCopilotCloudEnabled, readCopilotCloudOptIn, findUnmanagedCloudFiles } from './github-copilot/cloud-agent.js';
28
28
  const require = createRequire(import.meta.url);
29
29
  const { version: OPENSPEC_VERSION } = require('../../package.json');
@@ -135,7 +135,7 @@ export class UpdateCommand {
135
135
  // Still check for new tool directories and extra workflows
136
136
  this.detectNewTools(resolvedProjectPath, configuredTools);
137
137
  this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows);
138
- this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows);
138
+ this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
139
139
  this.displaySetupNotes(configuredTools);
140
140
  return;
141
141
  }
@@ -155,6 +155,7 @@ export class UpdateCommand {
155
155
  // 10. Update tools (all if force, otherwise only those needing update)
156
156
  const toolsToUpdate = this.force ? configuredTools : [...toolsToUpdateSet];
157
157
  const updatedTools = [];
158
+ const updatedToolIds = [];
158
159
  const failedTools = [];
159
160
  const skillsInvocableCommandSkips = [];
160
161
  const zeroArtifactTools = [];
@@ -223,6 +224,7 @@ export class UpdateCommand {
223
224
  }
224
225
  spinner.succeed(`Updated ${tool.name}`);
225
226
  updatedTools.push(tool.name);
227
+ updatedToolIds.push(tool.value);
226
228
  for (const migration of migrateLegacyToolDirs(resolvedProjectPath, [tool.value], 'after-generation')) {
227
229
  if (hasMovableContent(migration)) {
228
230
  console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
@@ -310,7 +312,7 @@ export class UpdateCommand {
310
312
  this.detectNewTools(resolvedProjectPath, configuredAndNewTools);
311
313
  // 14. Display note about extra workflows not in profile
312
314
  this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows);
313
- this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows);
315
+ this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
314
316
  this.displaySetupNotes(configuredAndNewTools);
315
317
  // 15. List affected tools
316
318
  if (updatedTools.length > 0) {
@@ -318,7 +320,16 @@ export class UpdateCommand {
318
320
  console.log(chalk.dim(`Tools: ${toolDisplayNames.join(', ')}`));
319
321
  }
320
322
  console.log();
321
- console.log(chalk.dim('Restart your IDE for changes to take effect.'));
323
+ const affectedToolIds = [...new Set([...newlyConfiguredTools, ...updatedToolIds])];
324
+ const shouldRestartIde = affectedToolIds.some((toolId) => {
325
+ const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
326
+ return Boolean(tool?.requiresIdeRestart &&
327
+ (shouldGenerateCommandsForTool(toolId, delivery) ||
328
+ shouldGenerateSkillsForTool(toolId, delivery)));
329
+ });
330
+ if (shouldRestartIde) {
331
+ console.log(chalk.dim('Restart your IDE for changes to take effect.'));
332
+ }
322
333
  if (failedTools.length > 0) {
323
334
  throw new Error(`OpenSpec update failed for: ${failedTools.map((tool) => tool.name).join(', ')}`);
324
335
  }
@@ -647,7 +658,7 @@ export class UpdateCommand {
647
658
  const canPrompt = isInteractive();
648
659
  if (this.force) {
649
660
  const legacyUpgrade = await this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
650
- await this.performImmediateLegacyCleanup(projectPath, detection);
661
+ await this.performImmediateLegacyCleanup(projectPath, detection, legacyUpgrade.skippedSharedSkillTools);
651
662
  return {
652
663
  ...legacyUpgrade,
653
664
  deferredGlobalCleanup: pickGlobalLegacyPromptFiles(detection, detection.globalSlashCommandFiles),
@@ -668,7 +679,7 @@ export class UpdateCommand {
668
679
  });
669
680
  if (shouldCleanup) {
670
681
  const legacyUpgrade = await this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
671
- await this.performImmediateLegacyCleanup(projectPath, detection);
682
+ await this.performImmediateLegacyCleanup(projectPath, detection, legacyUpgrade.skippedSharedSkillTools);
672
683
  return {
673
684
  ...legacyUpgrade,
674
685
  deferredGlobalCleanup: pickGlobalLegacyPromptFiles(detection, detection.globalSlashCommandFiles),
@@ -683,8 +694,10 @@ export class UpdateCommand {
683
694
  /**
684
695
  * Cleans approved repo-local legacy artifacts before configured tools refresh.
685
696
  */
686
- async performImmediateLegacyCleanup(projectPath, detection) {
687
- const immediateDetection = omitGlobalLegacyPromptFiles(detection);
697
+ async performImmediateLegacyCleanup(projectPath, detection, skippedSharedSkillTools = []) {
698
+ // Tools whose upgrade was skipped (shared root owned by another) had no
699
+ // replacement written, so their repo-local legacy files must be preserved.
700
+ const immediateDetection = omitToolLegacyArtifacts(omitGlobalLegacyPromptFiles(detection), skippedSharedSkillTools);
688
701
  if (immediateDetection.hasLegacyArtifacts) {
689
702
  await this.performLegacyCleanup(projectPath, immediateDetection);
690
703
  }
@@ -785,9 +798,10 @@ export class UpdateCommand {
785
798
  return { newlyConfiguredTools: [], workflowOverrides: {} };
786
799
  }
787
800
  }
788
- const inferredCodexWorkflows = getLegacyWorkflowIdsForTool(detection, 'codex');
801
+ const inferredCodexWorkflows = getProfileWorkflows('custom', getLegacyWorkflowIdsForTool(detection, 'codex')).filter((workflow) => ALL_WORKFLOWS.includes(workflow));
789
802
  // Create skills/commands for selected tools using effective profile+delivery.
790
803
  const newlyConfigured = [];
804
+ const skippedSharedSkillTools = [];
791
805
  const workflowOverrides = {};
792
806
  for (const toolId of selectedTools) {
793
807
  const tool = AI_TOOLS.find((t) => t.value === toolId);
@@ -807,6 +821,30 @@ export class UpdateCommand {
807
821
  }
808
822
  const skillTemplates = getSkillTemplates(toolWorkflows);
809
823
  const commandContents = getCommandContents(toolWorkflows);
824
+ // A shared skills root (e.g. `.agents`) already owned by another tool
825
+ // must not be overwritten by a tool inferred from legacy artifacts: a
826
+ // Codex install detected only from global `~/.codex/prompts` would
827
+ // otherwise rewrite an existing vendor-neutral `agents` tree with
828
+ // Codex-specific syntax and flip its ownership marker `agents → codex`.
829
+ // Leave the established owner in place. (init applies the same
830
+ // one-writer rule up front when both targets are selected.)
831
+ //
832
+ // Skipping here means the tool is never recorded as configured, so a
833
+ // persistent legacy signal re-offers it on later runs. Because no
834
+ // replacement is written, this tool is also exempted from immediate
835
+ // legacy cleanup (see skippedSharedSkillTools) — otherwise a repo-local
836
+ // `.codex/prompts` would be deleted with nothing put in its place. That
837
+ // repeat is idempotent and harmless — the alternative is the silent
838
+ // hijack this prevents.
839
+ const sharedOwner = shouldGenerateSkills
840
+ ? sharedSkillRootOwner(projectPath, tool.value)
841
+ : undefined;
842
+ if (sharedOwner) {
843
+ const ownerName = AI_TOOLS.find((candidate) => candidate.value === sharedOwner)?.name ?? sharedOwner;
844
+ spinner.info(`Skipped ${tool.name}: ${tool.skillsDir}/skills is already managed by another tool (${ownerName}).`);
845
+ skippedSharedSkillTools.push(tool.value);
846
+ continue;
847
+ }
810
848
  // Create skill files when delivery includes skills
811
849
  if (shouldGenerateSkills) {
812
850
  for (const { template, dirName } of skillTemplates) {
@@ -847,7 +885,7 @@ export class UpdateCommand {
847
885
  if (newlyConfigured.length > 0) {
848
886
  console.log();
849
887
  }
850
- return { newlyConfiguredTools: newlyConfigured, workflowOverrides };
888
+ return { newlyConfiguredTools: newlyConfigured, workflowOverrides, skippedSharedSkillTools };
851
889
  }
852
890
  }
853
891
  //# sourceMappingURL=update.js.map
@@ -0,0 +1,16 @@
1
+ export interface TaskNumberingDocument {
2
+ path: string;
3
+ content: string;
4
+ }
5
+ export interface TaskNumberingIssue {
6
+ path: string;
7
+ line: number;
8
+ message: string;
9
+ }
10
+ /**
11
+ * Finds ambiguous task references across the task files tracked by a change.
12
+ * Numbering is interpreted only inside `## N.` groups. Unnumbered sections,
13
+ * unnumbered tasks, and files without numbered groups are intentionally ignored.
14
+ */
15
+ export declare function findTaskNumberingIssues(documents: readonly TaskNumberingDocument[]): TaskNumberingIssue[];
16
+ //# sourceMappingURL=task-numbering.d.ts.map
@@ -0,0 +1,57 @@
1
+ import { parseTaskLines } from '../../utils/task-progress.js';
2
+ const LEVEL_TWO_HEADING = /^ {0,3}##(?!#)(?:[ \t]+|[ \t]*\r?$)/;
3
+ const NUMBERED_GROUP_HEADING = /^ {0,3}##[ \t]+(\d+)\.(?:[ \t]|\r?$)/;
4
+ const TASK_ID = /^(\d+(?:\.\d+)+(?:[A-Za-z]+)?)(?=\s|$)/;
5
+ /**
6
+ * Finds ambiguous task references across the task files tracked by a change.
7
+ * Numbering is interpreted only inside `## N.` groups. Unnumbered sections,
8
+ * unnumbered tasks, and files without numbered groups are intentionally ignored.
9
+ */
10
+ export function findTaskNumberingIssues(documents) {
11
+ const issues = [];
12
+ const firstLocationById = new Map();
13
+ for (const document of documents) {
14
+ const lines = document.content.split('\n');
15
+ if (!lines.some((line) => NUMBERED_GROUP_HEADING.test(line)))
16
+ continue;
17
+ let currentGroup;
18
+ lines.forEach((line, index) => {
19
+ if (LEVEL_TWO_HEADING.test(line)) {
20
+ currentGroup = line.match(NUMBERED_GROUP_HEADING)?.[1];
21
+ }
22
+ if (currentGroup === undefined)
23
+ return;
24
+ const task = parseTaskLines(line)[0];
25
+ const id = task?.description.match(TASK_ID)?.[1];
26
+ if (!id)
27
+ return;
28
+ const lineNumber = index + 1;
29
+ const taskGroup = id.split('.')[0];
30
+ const normalizedTaskGroup = taskGroup.replace(/^0+(?=\d)/, '');
31
+ const normalizedCurrentGroup = currentGroup.replace(/^0+(?=\d)/, '');
32
+ if (normalizedTaskGroup !== normalizedCurrentGroup) {
33
+ issues.push({
34
+ path: document.path,
35
+ line: lineNumber,
36
+ message: `Task "${id}" is under group ${currentGroup}, but its leading number points to group ${taskGroup}. Move it to group ${taskGroup} or renumber it.`,
37
+ });
38
+ }
39
+ const firstLocation = firstLocationById.get(id);
40
+ if (firstLocation !== undefined) {
41
+ const firstDeclaration = firstLocation.path === document.path
42
+ ? `on line ${firstLocation.line}`
43
+ : `in ${firstLocation.path} on line ${firstLocation.line}`;
44
+ issues.push({
45
+ path: document.path,
46
+ line: lineNumber,
47
+ message: `Task ID "${id}" is duplicated; it was first declared ${firstDeclaration}.`,
48
+ });
49
+ }
50
+ else {
51
+ firstLocationById.set(id, { path: document.path, line: lineNumber });
52
+ }
53
+ });
54
+ }
55
+ return issues;
56
+ }
57
+ //# sourceMappingURL=task-numbering.js.map
@@ -20,12 +20,15 @@ export declare class Validator {
20
20
  *
21
21
  * When `options.mainSpecsDir` is given, MODIFIED blocks are also checked
22
22
  * against the current main specs for the scenario loss archive refuses to
23
- * apply (#1477). Omitting it keeps the change-only checks, so callers with
24
- * no main specs root (and existing library callers) behave as before.
23
+ * apply (#1477). When `options.projectRoot` is given, the schema's tracked
24
+ * task files are checked for ambiguous numbering (#1520). Omitting either
25
+ * option keeps existing library and archive callers behaving as before.
25
26
  */
26
27
  validateChangeDeltaSpecs(changeDir: string, options?: {
27
28
  mainSpecsDir?: string;
29
+ projectRoot?: string;
28
30
  }): Promise<ValidationReport>;
31
+ private collectTaskNumberingIssues;
29
32
  /**
30
33
  * Report MODIFIED requirements whose block omits a scenario the main spec
31
34
  * still carries. Uses the same comparison archive applies, so validate can
@@ -9,7 +9,10 @@ import { extractRequirementBody as extractRequirementBodyShared, containsShallOr
9
9
  import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
10
10
  import { FileSystemUtils } from '../../utils/file-system.js';
11
11
  import { discoverSpecFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
12
- import { METADATA_FILENAME, readSkipSpecsMarker } from '../../utils/change-metadata.js';
12
+ import { METADATA_FILENAME, readSkipSpecsMarker, resolveSchemaForChange, } from '../../utils/change-metadata.js';
13
+ import { resolveTaskFilesForChange } from '../../utils/task-progress.js';
14
+ import { findTaskNumberingIssues } from './task-numbering.js';
15
+ import { getPackageSchemasDir, getSchemaDir } from '../artifact-graph/index.js';
13
16
  export class Validator {
14
17
  strictMode;
15
18
  constructor(strictMode = false) {
@@ -109,8 +112,9 @@ export class Validator {
109
112
  *
110
113
  * When `options.mainSpecsDir` is given, MODIFIED blocks are also checked
111
114
  * against the current main specs for the scenario loss archive refuses to
112
- * apply (#1477). Omitting it keeps the change-only checks, so callers with
113
- * no main specs root (and existing library callers) behave as before.
115
+ * apply (#1477). When `options.projectRoot` is given, the schema's tracked
116
+ * task files are checked for ambiguous numbering (#1520). Omitting either
117
+ * option keeps existing library and archive callers behaving as before.
114
118
  */
115
119
  async validateChangeDeltaSpecs(changeDir, options = {}) {
116
120
  const issues = [];
@@ -387,8 +391,58 @@ export class Validator {
387
391
  issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) });
388
392
  }
389
393
  }
394
+ if (options.projectRoot) {
395
+ issues.push(...await this.collectTaskNumberingIssues(changeDir, options.projectRoot));
396
+ }
390
397
  return this.createReport(issues);
391
398
  }
399
+ async collectTaskNumberingIssues(changeDir, projectRoot) {
400
+ try {
401
+ const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot).replace(/\.ya?ml$/, '');
402
+ const schemaDir = getSchemaDir(schemaName, projectRoot);
403
+ const builtInSchemaDir = path.join(getPackageSchemasDir(), 'spec-driven');
404
+ if (schemaName !== 'spec-driven' ||
405
+ schemaDir === null ||
406
+ FileSystemUtils.canonicalizeExistingPath(schemaDir) !==
407
+ FileSystemUtils.canonicalizeExistingPath(builtInSchemaDir)) {
408
+ return [];
409
+ }
410
+ }
411
+ catch {
412
+ return [];
413
+ }
414
+ let taskFiles;
415
+ try {
416
+ taskFiles = resolveTaskFilesForChange(changeDir, projectRoot);
417
+ }
418
+ catch {
419
+ return [];
420
+ }
421
+ if (taskFiles.length === 0) {
422
+ taskFiles = [path.join(changeDir, 'tasks.md')];
423
+ }
424
+ const documents = [];
425
+ for (const taskFile of taskFiles) {
426
+ let content;
427
+ try {
428
+ content = await fs.readFile(taskFile, 'utf-8');
429
+ }
430
+ catch {
431
+ continue;
432
+ }
433
+ documents.push({
434
+ path: FileSystemUtils.toPosixPath(path.relative(changeDir, taskFile)),
435
+ content,
436
+ });
437
+ }
438
+ documents.sort((left, right) => left.path.localeCompare(right.path));
439
+ return findTaskNumberingIssues(documents).map((issue) => ({
440
+ level: 'WARNING',
441
+ path: issue.path,
442
+ line: issue.line,
443
+ message: issue.message,
444
+ }));
445
+ }
392
446
  /**
393
447
  * Report MODIFIED requirements whose block omits a scenario the main spec
394
448
  * still carries. Uses the same comparison archive applies, so validate can
@@ -27,7 +27,9 @@ export declare function trackCommand(commandName: string, version: string): Prom
27
27
  /**
28
28
  * Show first-run telemetry notice if not already seen.
29
29
  */
30
- export declare function maybeShowTelemetryNotice(): Promise<void>;
30
+ export declare function maybeShowTelemetryNotice(options?: {
31
+ silent?: boolean;
32
+ }): Promise<void>;
31
33
  /**
32
34
  * Flush pending telemetry events.
33
35
  * Call this before CLI exit.
@@ -159,7 +159,7 @@ export async function trackCommand(commandName, version) {
159
159
  /**
160
160
  * Show first-run telemetry notice if not already seen.
161
161
  */
162
- export async function maybeShowTelemetryNotice() {
162
+ export async function maybeShowTelemetryNotice(options = {}) {
163
163
  if (!isTelemetryEnabled()) {
164
164
  return;
165
165
  }
@@ -168,8 +168,15 @@ export async function maybeShowTelemetryNotice() {
168
168
  if (config.noticeSeen) {
169
169
  return;
170
170
  }
171
- // Display notice
172
- console.log('Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0 or openspec config set telemetry.enabled false');
171
+ // In --json mode the notice would pollute stdout and break parsers, so
172
+ // defer it: skip the notice AND leave noticeSeen unset so the disclosure
173
+ // still appears on the user's first later non-JSON run.
174
+ if (options.silent) {
175
+ return;
176
+ }
177
+ // Display notice on stderr, not stdout: stdout is reserved for command
178
+ // output (raw passthrough text, JSON, etc.) and must stay parser/pipe-safe.
179
+ console.error('Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0 or openspec config set telemetry.enabled false');
173
180
  // Mark as seen
174
181
  await updateTelemetryConfig({ noticeSeen: true });
175
182
  }
@@ -179,6 +179,18 @@ export function readRetireCapabilitiesMarker(changeDir) {
179
179
  * name. One body rather than two, so a marker can never drift into honoring
180
180
  * metadata the other rejects - the whole point of the contract described above.
181
181
  */
182
+ /**
183
+ * A marker that cannot be honored, with its reason made safe to print.
184
+ *
185
+ * Every reason quotes something the author wrote - a schema name, a parser
186
+ * message carrying one, a filesystem error carrying a path - and callers print
187
+ * it straight to a terminal (`openspec archive`, `openspec validate`). A raw CR
188
+ * could forge a line of its own and an ESC could redraw the screen, so control
189
+ * characters never leave this function.
190
+ */
191
+ function unhonorable(reason) {
192
+ return { declared: false, invalidReason: reason.replace(/[\u0000-\u001f\u007f]/g, '?') };
193
+ }
182
194
  function readBooleanMarker(changeDir, key) {
183
195
  let raw;
184
196
  try {
@@ -193,10 +205,7 @@ function readBooleanMarker(changeDir, key) {
193
205
  // set cannot be determined - fail closed rather than let archive treat
194
206
  // the change as unmarked while every metadata-reading surface errors.
195
207
  const message = err instanceof Error ? err.message : String(err);
196
- return {
197
- declared: false,
198
- invalidReason: `the metadata file cannot be read (${message})`,
199
- };
208
+ return unhonorable(`the metadata file cannot be read (${message})`);
200
209
  }
201
210
  let parsed;
202
211
  try {
@@ -206,9 +215,7 @@ function readBooleanMarker(changeDir, key) {
206
215
  // Anchored so a comment like "# maybe add skip_specs later" does not
207
216
  // claim the marker was set.
208
217
  const mentioned = new RegExp(`^\\s*(['"]?)${key}\\1\\s*:`, 'm').test(raw);
209
- return mentioned
210
- ? { declared: false, invalidReason: 'the file is not valid YAML' }
211
- : { declared: false };
218
+ return mentioned ? unhonorable('the file is not valid YAML') : { declared: false };
212
219
  }
213
220
  const result = ChangeMetadataSchema.safeParse(parsed);
214
221
  if (result.success) {
@@ -225,16 +232,13 @@ function readBooleanMarker(changeDir, key) {
225
232
  try {
226
233
  const projectRoot = path.resolve(changeDir, '../../..');
227
234
  if (!listSchemas(projectRoot).includes(result.data.schema)) {
228
- return {
229
- declared: false,
230
- invalidReason: `schema: unknown schema '${result.data.schema}'`,
231
- };
235
+ return unhonorable(`schema: unknown schema '${result.data.schema}'`);
232
236
  }
233
237
  resolveSchema(result.data.schema, projectRoot);
234
238
  }
235
239
  catch (err) {
236
240
  const message = err instanceof Error ? err.message : String(err);
237
- return { declared: false, invalidReason: message };
241
+ return unhonorable(message);
238
242
  }
239
243
  return { declared: true };
240
244
  }
@@ -250,7 +254,7 @@ function readBooleanMarker(changeDir, key) {
250
254
  if (markerMentioned) {
251
255
  const first = result.error.issues[0];
252
256
  const where = first.path.length > 0 ? `${first.path.join('.')}: ` : '';
253
- return { declared: false, invalidReason: `${where}${first.message}` };
257
+ return unhonorable(`${where}${first.message}`);
254
258
  }
255
259
  return { declared: false };
256
260
  }
@@ -4,6 +4,8 @@ import { writeChangeMetadata, validateSchemaName } from './change-metadata.js';
4
4
  import { formatLocalDate } from './date.js';
5
5
  import { readProjectConfig } from '../core/project-config.js';
6
6
  import { isKebabId } from '../core/id.js';
7
+ import { resolveSchema } from '../core/artifact-graph/resolver.js';
8
+ import { isSpecsArtifactPath } from '../core/artifact-graph/outputs.js';
7
9
  const DEFAULT_SCHEMA = 'spec-driven';
8
10
  /**
9
11
  * Validates that a change name follows kebab-case conventions.
@@ -117,6 +119,8 @@ export async function createChange(projectRoot, name, options = {}) {
117
119
  if (await FileSystemUtils.directoryExists(changeDir)) {
118
120
  throw new Error(`Change '${name}' already exists at ${changeDir}`);
119
121
  }
122
+ const schema = resolveSchema(schemaName, projectRoot);
123
+ const skipsSpecs = !schema.artifacts.some(artifact => isSpecsArtifactPath(artifact.generates));
120
124
  // Creating a change may scaffold or complete the root itself (an
121
125
  // implicit root, or a config-only/incomplete clone). Never leave a
122
126
  // half-root behind that doctor immediately calls unhealthy: ensure
@@ -138,6 +142,7 @@ export async function createChange(projectRoot, name, options = {}) {
138
142
  writeChangeMetadata(changeDir, {
139
143
  schema: schemaName,
140
144
  created: formatLocalDate(),
145
+ ...(skipsSpecs ? { skip_specs: true } : {}),
141
146
  ...options.metadata,
142
147
  }, projectRoot);
143
148
  return { schema: schemaName, changeDir };
@@ -1,3 +1,4 @@
1
+ import type { Readable, Writable } from 'node:stream';
1
2
  export type InteractiveOptions = {
2
3
  /**
3
4
  * Explicit "disable prompts" flag passed by internal callers.
@@ -35,7 +36,35 @@ export declare function isInteractive(value?: boolean | InteractiveOptions): boo
35
36
  * somebody was there and chose to quit.
36
37
  *
37
38
  * Beyond that it defers to `isInteractive()`, so `CI`, `OPEN_SPEC_INTERACTIVE=0`
38
- * and `--no-interactive` count even when a runner allocated a pty.
39
+ * and `--no-interactive` count even when a runner allocated a pty. It also
40
+ * counts a redirected stdout: `confirmPrompt` drops to the plain reader whenever
41
+ * *either* stream is not a TTY, so a stdin-TTY-but-stdout-redirected run
42
+ * (`openspec archive x > log.txt` from a terminal) that hits EOF must classify
43
+ * the same way the prompt was selected — otherwise it would leak the raw
44
+ * `ExitPromptError` instead of the `--yes` guidance.
39
45
  */
40
46
  export declare function isNonInteractivePromptError(error: unknown, value?: boolean | InteractiveOptions): boolean;
47
+ export type ConfirmPrompt = {
48
+ message: string;
49
+ default: boolean;
50
+ };
51
+ /**
52
+ * Ask a yes/no question. A real terminal gets @inquirer's rich prompt;
53
+ * everything else — a pipe, a file redirect, an agent that captures stdout —
54
+ * reads one plain line instead.
55
+ *
56
+ * @inquirer renders `confirm` by writing ANSI cursor-movement escape sequences,
57
+ * and it emits them even when stdout is not a TTY. Redirected to a file those
58
+ * sequences are noise, and in some non-TTY hosts the render loop never settles
59
+ * and repeats `ESC[NNG` cursor moves until the disk fills (#1526). Reading
60
+ * the answer ourselves keeps the single piped answer @inquirer ever supported
61
+ * working (`printf 'y\n' | openspec archive ...`) without emitting any escapes.
62
+ *
63
+ * `io` overrides the streams; it exists for tests and mirrors @inquirer's own
64
+ * `{ input, output }` context. Production callers pass only the prompt.
65
+ */
66
+ export declare function confirmPrompt(prompt: ConfirmPrompt, io?: {
67
+ input?: Readable;
68
+ output?: Writable;
69
+ }): Promise<boolean>;
41
70
  //# sourceMappingURL=interactive.d.ts.map