@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.
- package/dist/cli/index.d.ts +32 -0
- package/dist/cli/index.js +80 -9
- package/dist/commands/change.js +1 -0
- package/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.js +19 -4
- package/dist/commands/feedback.js +28 -7
- package/dist/commands/schema.js +193 -28
- package/dist/commands/validate.d.ts +23 -0
- package/dist/commands/validate.js +148 -4
- package/dist/commands/workflow/schemas.d.ts +2 -0
- package/dist/commands/workflow/schemas.js +9 -2
- package/dist/core/archive.js +74 -10
- package/dist/core/artifact-graph/instruction-loader.js +2 -7
- package/dist/core/artifact-graph/outputs.d.ts +4 -0
- package/dist/core/artifact-graph/outputs.js +7 -0
- package/dist/core/artifact-graph/resolver.d.ts +0 -12
- package/dist/core/artifact-graph/resolver.js +14 -0
- package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
- package/dist/core/command-generation/adapters/command-code.js +34 -0
- package/dist/core/command-generation/adapters/index.d.ts +1 -0
- package/dist/core/command-generation/adapters/index.js +1 -0
- package/dist/core/command-generation/adapters/opencode.d.ts +2 -1
- package/dist/core/command-generation/adapters/opencode.js +13 -2
- package/dist/core/command-generation/registry.js +2 -0
- package/dist/core/completion-tip.d.ts +15 -0
- package/dist/core/completion-tip.js +133 -0
- package/dist/core/completions/command-registry.js +10 -0
- package/dist/core/completions/factory.d.ts +11 -0
- package/dist/core/completions/installers/bash-installer.d.ts +7 -0
- package/dist/core/completions/installers/bash-installer.js +15 -0
- package/dist/core/completions/installers/fish-installer.d.ts +7 -0
- package/dist/core/completions/installers/fish-installer.js +15 -0
- package/dist/core/completions/installers/powershell-installer.d.ts +7 -0
- package/dist/core/completions/installers/powershell-installer.js +15 -0
- package/dist/core/config-prompts.js +19 -10
- package/dist/core/config-schema.d.ts +1 -0
- package/dist/core/config-schema.js +2 -0
- package/dist/core/config.d.ts +1 -0
- package/dist/core/config.js +17 -15
- package/dist/core/global-config.d.ts +2 -0
- package/dist/core/init.d.ts +5 -0
- package/dist/core/init.js +110 -14
- package/dist/core/legacy-cleanup.d.ts +10 -0
- package/dist/core/legacy-cleanup.js +48 -26
- package/dist/core/parsers/requirement-blocks.js +41 -9
- package/dist/core/parsers/requirement-text.d.ts +8 -0
- package/dist/core/parsers/requirement-text.js +4 -3
- package/dist/core/profiles.d.ts +1 -1
- package/dist/core/profiles.js +11 -2
- package/dist/core/root-selection.d.ts +1 -1
- package/dist/core/shared-skill-target.d.ts +15 -0
- package/dist/core/shared-skill-target.js +30 -0
- package/dist/core/specs-apply.js +11 -4
- package/dist/core/templates/workflows/apply-change.js +3 -0
- package/dist/core/templates/workflows/feedback.js +1 -0
- package/dist/core/templates/workflows/onboard.js +4 -4
- package/dist/core/templates/workflows/propose.js +2 -2
- package/dist/core/templates/workflows/store-selection.d.ts +1 -1
- package/dist/core/templates/workflows/store-selection.js +1 -1
- package/dist/core/templates/workflows/update-change.js +4 -4
- package/dist/core/update.js +49 -11
- package/dist/core/validation/task-numbering.d.ts +16 -0
- package/dist/core/validation/task-numbering.js +57 -0
- package/dist/core/validation/validator.d.ts +5 -2
- package/dist/core/validation/validator.js +57 -3
- package/dist/telemetry/index.d.ts +3 -1
- package/dist/telemetry/index.js +10 -3
- package/dist/utils/change-metadata.js +17 -13
- package/dist/utils/change-utils.js +5 -0
- package/dist/utils/interactive.d.ts +30 -1
- package/dist/utils/interactive.js +105 -2
- package/dist/utils/task-progress.d.ts +34 -1
- package/dist/utils/task-progress.js +52 -28
- package/package.json +3 -6
- package/schemas/spec-driven/schema.yaml +17 -7
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/dist/core/update.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
24
|
-
*
|
|
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).
|
|
113
|
-
*
|
|
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(
|
|
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.
|
package/dist/telemetry/index.js
CHANGED
|
@@ -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
|
-
//
|
|
172
|
-
|
|
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
|
|
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
|
|
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
|