@fission-ai/openspec 1.8.0 → 1.9.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 (48) hide show
  1. package/dist/cli/index.d.ts +15 -0
  2. package/dist/cli/index.js +33 -4
  3. package/dist/commands/change.js +1 -0
  4. package/dist/commands/config.d.ts +5 -0
  5. package/dist/commands/config.js +5 -1
  6. package/dist/commands/schema.js +193 -28
  7. package/dist/commands/validate.d.ts +23 -0
  8. package/dist/commands/validate.js +148 -4
  9. package/dist/commands/workflow/schemas.d.ts +2 -0
  10. package/dist/commands/workflow/schemas.js +9 -2
  11. package/dist/core/archive.js +10 -3
  12. package/dist/core/artifact-graph/resolver.d.ts +0 -12
  13. package/dist/core/artifact-graph/resolver.js +14 -0
  14. package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
  15. package/dist/core/command-generation/adapters/command-code.js +34 -0
  16. package/dist/core/command-generation/adapters/index.d.ts +1 -0
  17. package/dist/core/command-generation/adapters/index.js +1 -0
  18. package/dist/core/command-generation/registry.js +2 -0
  19. package/dist/core/completions/command-registry.js +5 -0
  20. package/dist/core/config.d.ts +1 -0
  21. package/dist/core/config.js +16 -15
  22. package/dist/core/init.js +19 -7
  23. package/dist/core/legacy-cleanup.d.ts +10 -0
  24. package/dist/core/legacy-cleanup.js +48 -26
  25. package/dist/core/parsers/requirement-blocks.js +41 -9
  26. package/dist/core/parsers/requirement-text.d.ts +8 -0
  27. package/dist/core/parsers/requirement-text.js +4 -3
  28. package/dist/core/root-selection.d.ts +1 -1
  29. package/dist/core/shared-skill-target.d.ts +15 -0
  30. package/dist/core/shared-skill-target.js +30 -0
  31. package/dist/core/specs-apply.js +11 -4
  32. package/dist/core/templates/workflows/apply-change.js +3 -0
  33. package/dist/core/templates/workflows/propose.js +2 -2
  34. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  35. package/dist/core/templates/workflows/store-selection.js +1 -1
  36. package/dist/core/templates/workflows/update-change.js +4 -4
  37. package/dist/core/update.js +34 -7
  38. package/dist/core/validation/task-numbering.d.ts +16 -0
  39. package/dist/core/validation/task-numbering.js +57 -0
  40. package/dist/core/validation/validator.d.ts +5 -2
  41. package/dist/core/validation/validator.js +57 -3
  42. package/dist/telemetry/index.d.ts +3 -1
  43. package/dist/telemetry/index.js +7 -1
  44. package/dist/utils/interactive.d.ts +30 -1
  45. package/dist/utils/interactive.js +105 -2
  46. package/dist/utils/task-progress.d.ts +34 -1
  47. package/dist/utils/task-progress.js +52 -28
  48. package/package.json +1 -1
@@ -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
@@ -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
@@ -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');
@@ -647,7 +647,7 @@ export class UpdateCommand {
647
647
  const canPrompt = isInteractive();
648
648
  if (this.force) {
649
649
  const legacyUpgrade = await this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
650
- await this.performImmediateLegacyCleanup(projectPath, detection);
650
+ await this.performImmediateLegacyCleanup(projectPath, detection, legacyUpgrade.skippedSharedSkillTools);
651
651
  return {
652
652
  ...legacyUpgrade,
653
653
  deferredGlobalCleanup: pickGlobalLegacyPromptFiles(detection, detection.globalSlashCommandFiles),
@@ -668,7 +668,7 @@ export class UpdateCommand {
668
668
  });
669
669
  if (shouldCleanup) {
670
670
  const legacyUpgrade = await this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
671
- await this.performImmediateLegacyCleanup(projectPath, detection);
671
+ await this.performImmediateLegacyCleanup(projectPath, detection, legacyUpgrade.skippedSharedSkillTools);
672
672
  return {
673
673
  ...legacyUpgrade,
674
674
  deferredGlobalCleanup: pickGlobalLegacyPromptFiles(detection, detection.globalSlashCommandFiles),
@@ -683,8 +683,10 @@ export class UpdateCommand {
683
683
  /**
684
684
  * Cleans approved repo-local legacy artifacts before configured tools refresh.
685
685
  */
686
- async performImmediateLegacyCleanup(projectPath, detection) {
687
- const immediateDetection = omitGlobalLegacyPromptFiles(detection);
686
+ async performImmediateLegacyCleanup(projectPath, detection, skippedSharedSkillTools = []) {
687
+ // Tools whose upgrade was skipped (shared root owned by another) had no
688
+ // replacement written, so their repo-local legacy files must be preserved.
689
+ const immediateDetection = omitToolLegacyArtifacts(omitGlobalLegacyPromptFiles(detection), skippedSharedSkillTools);
688
690
  if (immediateDetection.hasLegacyArtifacts) {
689
691
  await this.performLegacyCleanup(projectPath, immediateDetection);
690
692
  }
@@ -788,6 +790,7 @@ export class UpdateCommand {
788
790
  const inferredCodexWorkflows = getLegacyWorkflowIdsForTool(detection, 'codex');
789
791
  // Create skills/commands for selected tools using effective profile+delivery.
790
792
  const newlyConfigured = [];
793
+ const skippedSharedSkillTools = [];
791
794
  const workflowOverrides = {};
792
795
  for (const toolId of selectedTools) {
793
796
  const tool = AI_TOOLS.find((t) => t.value === toolId);
@@ -807,6 +810,30 @@ export class UpdateCommand {
807
810
  }
808
811
  const skillTemplates = getSkillTemplates(toolWorkflows);
809
812
  const commandContents = getCommandContents(toolWorkflows);
813
+ // A shared skills root (e.g. `.agents`) already owned by another tool
814
+ // must not be overwritten by a tool inferred from legacy artifacts: a
815
+ // Codex install detected only from global `~/.codex/prompts` would
816
+ // otherwise rewrite an existing vendor-neutral `agents` tree with
817
+ // Codex-specific syntax and flip its ownership marker `agents → codex`.
818
+ // Leave the established owner in place. (init applies the same
819
+ // one-writer rule up front when both targets are selected.)
820
+ //
821
+ // Skipping here means the tool is never recorded as configured, so a
822
+ // persistent legacy signal re-offers it on later runs. Because no
823
+ // replacement is written, this tool is also exempted from immediate
824
+ // legacy cleanup (see skippedSharedSkillTools) — otherwise a repo-local
825
+ // `.codex/prompts` would be deleted with nothing put in its place. That
826
+ // repeat is idempotent and harmless — the alternative is the silent
827
+ // hijack this prevents.
828
+ const sharedOwner = shouldGenerateSkills
829
+ ? sharedSkillRootOwner(projectPath, tool.value)
830
+ : undefined;
831
+ if (sharedOwner) {
832
+ const ownerName = AI_TOOLS.find((candidate) => candidate.value === sharedOwner)?.name ?? sharedOwner;
833
+ spinner.info(`Skipped ${tool.name}: ${tool.skillsDir}/skills is already managed by another tool (${ownerName}).`);
834
+ skippedSharedSkillTools.push(tool.value);
835
+ continue;
836
+ }
810
837
  // Create skill files when delivery includes skills
811
838
  if (shouldGenerateSkills) {
812
839
  for (const { template, dirName } of skillTemplates) {
@@ -847,7 +874,7 @@ export class UpdateCommand {
847
874
  if (newlyConfigured.length > 0) {
848
875
  console.log();
849
876
  }
850
- return { newlyConfiguredTools: newlyConfigured, workflowOverrides };
877
+ return { newlyConfiguredTools: newlyConfigured, workflowOverrides, skippedSharedSkillTools };
851
878
  }
852
879
  }
853
880
  //# 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,6 +168,12 @@ export async function maybeShowTelemetryNotice() {
168
168
  if (config.noticeSeen) {
169
169
  return;
170
170
  }
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
+ }
171
177
  // Display notice
172
178
  console.log('Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0 or openspec config set telemetry.enabled false');
173
179
  // Mark as seen
@@ -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
@@ -1,3 +1,4 @@
1
+ import { createInterface } from 'node:readline';
1
2
  /**
2
3
  * Resolves whether non-interactive mode is requested.
3
4
  * Handles both explicit `noInteractive: true` and Commander.js style `interactive: false`.
@@ -38,7 +39,12 @@ export function isInteractive(value) {
38
39
  * somebody was there and chose to quit.
39
40
  *
40
41
  * Beyond that it defers to `isInteractive()`, so `CI`, `OPEN_SPEC_INTERACTIVE=0`
41
- * and `--no-interactive` count even when a runner allocated a pty.
42
+ * and `--no-interactive` count even when a runner allocated a pty. It also
43
+ * counts a redirected stdout: `confirmPrompt` drops to the plain reader whenever
44
+ * *either* stream is not a TTY, so a stdin-TTY-but-stdout-redirected run
45
+ * (`openspec archive x > log.txt` from a terminal) that hits EOF must classify
46
+ * the same way the prompt was selected — otherwise it would leak the raw
47
+ * `ExitPromptError` instead of the `--yes` guidance.
42
48
  */
43
49
  export function isNonInteractivePromptError(error, value) {
44
50
  if (!(error instanceof Error))
@@ -48,6 +54,103 @@ export function isNonInteractivePromptError(error, value) {
48
54
  return false;
49
55
  if (error.message.includes('SIGINT'))
50
56
  return false;
51
- return !isInteractive(value);
57
+ return !isInteractive(value) || !process.stdout.isTTY;
58
+ }
59
+ /**
60
+ * Ask a yes/no question. A real terminal gets @inquirer's rich prompt;
61
+ * everything else — a pipe, a file redirect, an agent that captures stdout —
62
+ * reads one plain line instead.
63
+ *
64
+ * @inquirer renders `confirm` by writing ANSI cursor-movement escape sequences,
65
+ * and it emits them even when stdout is not a TTY. Redirected to a file those
66
+ * sequences are noise, and in some non-TTY hosts the render loop never settles
67
+ * and repeats `ESC[NNG` cursor moves until the disk fills (#1526). Reading
68
+ * the answer ourselves keeps the single piped answer @inquirer ever supported
69
+ * working (`printf 'y\n' | openspec archive ...`) without emitting any escapes.
70
+ *
71
+ * `io` overrides the streams; it exists for tests and mirrors @inquirer's own
72
+ * `{ input, output }` context. Production callers pass only the prompt.
73
+ */
74
+ export async function confirmPrompt(prompt, io = {}) {
75
+ const input = io.input ?? process.stdin;
76
+ const output = io.output ?? process.stdout;
77
+ const isTerminal = Boolean(input.isTTY) &&
78
+ Boolean(output.isTTY);
79
+ if (isTerminal) {
80
+ const { confirm } = await import('@inquirer/prompts');
81
+ return confirm(prompt);
82
+ }
83
+ return readYesNo(prompt, input, output);
84
+ }
85
+ function readYesNo(prompt, input, output) {
86
+ return new Promise((resolve, reject) => {
87
+ let settled = false;
88
+ // Detach the input-stream error listener on settle: `input` is the
89
+ // long-lived process.stdin, so a leftover listener would accumulate across
90
+ // archive's successive prompts and swallow a later, unrelated stdin error.
91
+ const cleanup = () => {
92
+ input.removeListener('error', onError);
93
+ };
94
+ const blockOnNoAnswer = () => {
95
+ if (settled)
96
+ return;
97
+ settled = true;
98
+ cleanup();
99
+ // No line could be read (stdin closed / EOF). Mirror @inquirer's failure
100
+ // so callers that classify it — isNonInteractivePromptError, the #1479
101
+ // "rerun with --yes" guidance — keep working unchanged.
102
+ const error = new Error('User force closed the prompt');
103
+ error.name = 'ExitPromptError';
104
+ reject(error);
105
+ };
106
+ // A stdin error surfaces on the interface (readline forwards input-stream
107
+ // errors since Node 16). Without a handler the promise would hang and the
108
+ // 'error' would go unhandled; settle it with the real fault instead.
109
+ const onError = (err) => {
110
+ if (settled)
111
+ return;
112
+ settled = true;
113
+ cleanup();
114
+ rl.close();
115
+ reject(err instanceof Error ? err : new Error(String(err)));
116
+ };
117
+ // An earlier prompt may have already drained stdin (only one piped answer
118
+ // was ever supported). A fresh readline over an ended stream never emits
119
+ // 'close', so guard here rather than hang and exit as a no-op.
120
+ if (input.readableEnded) {
121
+ blockOnNoAnswer();
122
+ return;
123
+ }
124
+ output.write(`${prompt.message} ${prompt.default ? '(Y/n)' : '(y/N)'} `);
125
+ // terminal:false guarantees readline never emits its own line-editing
126
+ // escapes — an escape-free read is the whole point here.
127
+ const rl = createInterface({ input, terminal: false });
128
+ input.once('error', onError);
129
+ rl.once('error', onError);
130
+ rl.once('line', (line) => {
131
+ if (settled)
132
+ return;
133
+ settled = true;
134
+ cleanup();
135
+ rl.close();
136
+ output.write('\n');
137
+ // Mirror @inquirer/confirm's parser (prefix match on y/yes and n/no,
138
+ // otherwise the default) so a piped answer resolves identically to the
139
+ // interactive prompt it replaces.
140
+ const answer = line.trim();
141
+ if (/^(y|yes)/i.test(answer)) {
142
+ resolve(true);
143
+ }
144
+ else if (/^(n|no)/i.test(answer)) {
145
+ resolve(false);
146
+ }
147
+ else {
148
+ resolve(prompt.default);
149
+ }
150
+ });
151
+ rl.once('close', () => {
152
+ blockOnNoAnswer();
153
+ });
154
+ });
52
155
  }
53
156
  //# sourceMappingURL=interactive.js.map
@@ -20,6 +20,27 @@ export interface TaskProgress {
20
20
  completed: number;
21
21
  }
22
22
  export declare function countTasksFromContent(content: string): TaskProgress;
23
+ /**
24
+ * Run-scoped memo mapping a schema name to its tracked-tasks `generates` glob.
25
+ * When one command resolves progress for many changes under a constant
26
+ * `projectRoot` — e.g. `validate --archived` over an append-only archive — this
27
+ * avoids re-reading and re-parsing (YAML + Zod) the same `schema.yaml` once per
28
+ * change. Keyed by schema name alone, which is safe *only* because a single run
29
+ * holds `projectRoot` constant; never reuse one cache across differing roots.
30
+ */
31
+ export type SchemaGlobCache = Map<string, string | undefined>;
32
+ /** Resolves the task files selected by the schema's apply tracking rule. */
33
+ export declare function resolveTaskFilesForChange(changeDir: string, projectRoot: string, schemaGlobCache?: SchemaGlobCache): string[];
34
+ export interface TaskProgressDetail extends TaskProgress {
35
+ /**
36
+ * Task files that exist but could not be read (any error other than ENOENT).
37
+ * `getTaskProgressForChange` discards this list to preserve its behavior;
38
+ * callers that must fail loudly on an unreadable tasks file — e.g.
39
+ * `openspec validate --archived` — read it so an unreadable file is never
40
+ * silently counted as "no tasks" (#205).
41
+ */
42
+ unreadable: string[];
43
+ }
23
44
  /**
24
45
  * Computes a change's task progress by resolving its tracked-tasks artifact and
25
46
  * counting checkboxes across every file matched by that artifact's `generates`
@@ -27,7 +48,19 @@ export declare function countTasksFromContent(content: string): TaskProgress;
27
48
  * artifact (`resolveArtifactOutputs`) — so progress is no longer blind to nested
28
49
  * `tasks.md` files (#1202). Falls back to a single top-level `tasks.md` (exactly
29
50
  * as before) when the schema is unresolvable, no tracked-tasks artifact is found,
30
- * or the glob matches no file. Never throws.
51
+ * or the glob matches no file. Also reports task files that exist but could not
52
+ * be read. Per-file read errors are captured (never thrown); the only throw path
53
+ * is a malformed/unsafe schema whose glob resolution rejects (path traversal or
54
+ * a linked-directory cycle in `resolveArtifactOutputs`). Pass `schemaGlobCache`
55
+ * to memoize schema→glob resolution across many changes in one run.
56
+ */
57
+ export declare function getTaskProgressDetailForChange(changesDir: string, changeName: string, projectRoot: string, schemaGlobCache?: SchemaGlobCache): Promise<TaskProgressDetail>;
58
+ /**
59
+ * The task-completion counter `status`, `list`, and `archive` share. Delegates
60
+ * to `getTaskProgressDetailForChange` and drops the `unreadable` detail, so its
61
+ * returned totals are unchanged. Throws only on the same malformed/unsafe-schema
62
+ * glob-resolution path as that function (existing behavior; callers guard it as
63
+ * they did before).
31
64
  */
32
65
  export declare function getTaskProgressForChange(changesDir: string, changeName: string, projectRoot: string): Promise<TaskProgress>;
33
66
  export declare function formatTaskStatus(progress: TaskProgress): string;