@fission-ai/openspec 1.9.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 +17 -0
- package/dist/cli/index.js +49 -7
- package/dist/commands/config.js +14 -3
- package/dist/commands/feedback.js +28 -7
- package/dist/core/archive.js +64 -7
- 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/command-generation/adapters/opencode.d.ts +2 -1
- package/dist/core/command-generation/adapters/opencode.js +13 -2
- package/dist/core/completion-tip.d.ts +15 -0
- package/dist/core/completion-tip.js +133 -0
- package/dist/core/completions/command-registry.js +5 -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.js +1 -0
- package/dist/core/global-config.d.ts +2 -0
- package/dist/core/init.d.ts +5 -0
- package/dist/core/init.js +91 -7
- package/dist/core/profiles.d.ts +1 -1
- package/dist/core/profiles.js +11 -2
- package/dist/core/templates/workflows/feedback.js +1 -0
- package/dist/core/templates/workflows/onboard.js +4 -4
- package/dist/core/update.js +15 -4
- package/dist/telemetry/index.js +3 -2
- package/dist/utils/change-metadata.js +17 -13
- package/dist/utils/change-utils.js +5 -0
- package/package.json +3 -6
- package/schemas/spec-driven/schema.yaml +17 -7
- package/scripts/postinstall.js +0 -83
|
@@ -55,6 +55,21 @@ export class BashInstaller {
|
|
|
55
55
|
// For user installation, use local directory
|
|
56
56
|
return path.join(localCompletionDir, 'openspec');
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Check if a completion script is currently installed.
|
|
60
|
+
* Mirrors ZshInstaller.isInstalled so callers can ask any installer.
|
|
61
|
+
*
|
|
62
|
+
* @returns true if the completion script exists
|
|
63
|
+
*/
|
|
64
|
+
async isInstalled() {
|
|
65
|
+
try {
|
|
66
|
+
// stat, not access: a directory at the install path is not a script.
|
|
67
|
+
return (await fs.stat(await this.getInstallationPath())).isFile();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
58
73
|
/**
|
|
59
74
|
* Backup an existing completion file if it exists
|
|
60
75
|
*
|
|
@@ -12,6 +12,13 @@ export declare class FishInstaller {
|
|
|
12
12
|
* @returns Installation path
|
|
13
13
|
*/
|
|
14
14
|
getInstallationPath(): string;
|
|
15
|
+
/**
|
|
16
|
+
* Check if a completion script is currently installed.
|
|
17
|
+
* Mirrors ZshInstaller.isInstalled so callers can ask any installer.
|
|
18
|
+
*
|
|
19
|
+
* @returns true if the completion script exists
|
|
20
|
+
*/
|
|
21
|
+
isInstalled(): Promise<boolean>;
|
|
15
22
|
/**
|
|
16
23
|
* Backup an existing completion file if it exists
|
|
17
24
|
*
|
|
@@ -19,6 +19,21 @@ export class FishInstaller {
|
|
|
19
19
|
getInstallationPath() {
|
|
20
20
|
return path.join(this.homeDir, '.config', 'fish', 'completions', 'openspec.fish');
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Check if a completion script is currently installed.
|
|
24
|
+
* Mirrors ZshInstaller.isInstalled so callers can ask any installer.
|
|
25
|
+
*
|
|
26
|
+
* @returns true if the completion script exists
|
|
27
|
+
*/
|
|
28
|
+
async isInstalled() {
|
|
29
|
+
try {
|
|
30
|
+
// stat, not access: a directory at the install path is not a script.
|
|
31
|
+
return (await fs.stat(this.getInstallationPath())).isFile();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
22
37
|
/**
|
|
23
38
|
* Backup an existing completion file if it exists
|
|
24
39
|
*
|
|
@@ -43,6 +43,13 @@ export declare class PowerShellInstaller {
|
|
|
43
43
|
* @returns Installation path
|
|
44
44
|
*/
|
|
45
45
|
getInstallationPath(): string;
|
|
46
|
+
/**
|
|
47
|
+
* Check if a completion script is currently installed.
|
|
48
|
+
* Mirrors ZshInstaller.isInstalled so callers can ask any installer.
|
|
49
|
+
*
|
|
50
|
+
* @returns true if the completion script exists
|
|
51
|
+
*/
|
|
52
|
+
isInstalled(): Promise<boolean>;
|
|
46
53
|
/**
|
|
47
54
|
* Backup an existing completion file if it exists
|
|
48
55
|
*
|
|
@@ -110,6 +110,21 @@ export class PowerShellInstaller {
|
|
|
110
110
|
const profileDir = path.dirname(profilePath);
|
|
111
111
|
return path.join(profileDir, 'OpenSpecCompletion.ps1');
|
|
112
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Check if a completion script is currently installed.
|
|
115
|
+
* Mirrors ZshInstaller.isInstalled so callers can ask any installer.
|
|
116
|
+
*
|
|
117
|
+
* @returns true if the completion script exists
|
|
118
|
+
*/
|
|
119
|
+
async isInstalled() {
|
|
120
|
+
try {
|
|
121
|
+
// stat, not access: a directory at the install path is not a script.
|
|
122
|
+
return (await fs.stat(this.getInstallationPath())).isFile();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
113
128
|
/**
|
|
114
129
|
* Backup an existing completion file if it exists
|
|
115
130
|
*
|
|
@@ -9,16 +9,25 @@ export function serializeConfig(config) {
|
|
|
9
9
|
// Schema (required)
|
|
10
10
|
lines.push(`schema: ${config.schema}`);
|
|
11
11
|
lines.push('');
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
12
|
+
if (config.context !== undefined) {
|
|
13
|
+
lines.push('context: |');
|
|
14
|
+
for (const line of config.context.split('\n')) {
|
|
15
|
+
lines.push(` ${line}`);
|
|
16
|
+
}
|
|
17
|
+
lines.push('');
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
// Context section with comments
|
|
21
|
+
lines.push('# Project context (optional)');
|
|
22
|
+
lines.push('# This is shown to AI when creating artifacts.');
|
|
23
|
+
lines.push('# Add your tech stack, conventions, style guides, domain knowledge, etc.');
|
|
24
|
+
lines.push('# Example:');
|
|
25
|
+
lines.push('# context: |');
|
|
26
|
+
lines.push('# Tech stack: TypeScript, React, Node.js');
|
|
27
|
+
lines.push('# We use conventional commits');
|
|
28
|
+
lines.push('# Domain: e-commerce platform');
|
|
29
|
+
lines.push('');
|
|
30
|
+
}
|
|
22
31
|
// Rules section with comments
|
|
23
32
|
lines.push('# Per-artifact rules (optional)');
|
|
24
33
|
lines.push('# Add custom rules for specific artifacts.');
|
|
@@ -19,6 +19,7 @@ export declare const GlobalConfigSchema: z.ZodObject<{
|
|
|
19
19
|
telemetry: z.ZodOptional<z.ZodObject<{
|
|
20
20
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
21
21
|
}, z.core.$loose>>;
|
|
22
|
+
completionTipSeen: z.ZodOptional<z.ZodBoolean>;
|
|
22
23
|
}, z.core.$loose>;
|
|
23
24
|
export type GlobalConfigType = z.infer<typeof GlobalConfigSchema>;
|
|
24
25
|
/**
|
package/dist/core/config.js
CHANGED
|
@@ -54,6 +54,7 @@ export const AI_TOOLS = [
|
|
|
54
54
|
{ name: 'Rovo Dev CLI', value: 'rovodev', available: true, successLabel: 'Rovo Dev CLI', skillsDir: '.rovodev', detectionPaths: ['.rovodev/skills', '.rovodev'] },
|
|
55
55
|
{ name: 'Zoo Code', value: 'roocode', available: true, successLabel: 'Zoo Code', skillsDir: '.roo', requiresIdeRestart: true },
|
|
56
56
|
{ name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae', requiresIdeRestart: true },
|
|
57
|
+
{ name: 'Zed Agent', value: 'zed', available: true, successLabel: 'Zed Agent', skillsDir: '.agents', detectionPaths: ['.zed', '.agents/skills'] },
|
|
57
58
|
{ name: 'ZCode', value: 'zcode', available: true, successLabel: 'ZCode', skillsDir: '.zcode' },
|
|
58
59
|
// Vendor-neutral target for assistants that read the shared `.agents` root.
|
|
59
60
|
// Detection keys off `.agents/skills` rather than the bare root: frameworks use
|
|
@@ -26,6 +26,8 @@ export interface GlobalConfig {
|
|
|
26
26
|
openers?: unknown;
|
|
27
27
|
/** Anonymous usage analytics settings and identity. */
|
|
28
28
|
telemetry?: TelemetryConfig;
|
|
29
|
+
/** Whether the first-run shell-completions tip has been shown. */
|
|
30
|
+
completionTipSeen?: boolean;
|
|
29
31
|
}
|
|
30
32
|
/**
|
|
31
33
|
* Gets the global configuration directory path following XDG Base Directory Specification.
|
package/dist/core/init.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
type InitCommandOptions = {
|
|
8
8
|
tools?: string;
|
|
9
|
+
language?: string;
|
|
9
10
|
force?: boolean;
|
|
10
11
|
interactive?: boolean;
|
|
11
12
|
profile?: string;
|
|
@@ -20,6 +21,7 @@ type InitCommandOptions = {
|
|
|
20
21
|
};
|
|
21
22
|
export declare class InitCommand {
|
|
22
23
|
private readonly toolsArg?;
|
|
24
|
+
private readonly language?;
|
|
23
25
|
private readonly force;
|
|
24
26
|
private readonly interactiveOption?;
|
|
25
27
|
private readonly profileOverride?;
|
|
@@ -85,6 +87,9 @@ export declare class InitCommand {
|
|
|
85
87
|
* @returns Created, refreshed, and failed tools plus removed artifact counts
|
|
86
88
|
*/
|
|
87
89
|
private generateSkillsAndCommands;
|
|
90
|
+
private normalizeLanguage;
|
|
91
|
+
private languageContext;
|
|
92
|
+
private assertLanguageCanBeApplied;
|
|
88
93
|
private createConfig;
|
|
89
94
|
private displaySuccessMessage;
|
|
90
95
|
private startSpinner;
|
package/dist/core/init.js
CHANGED
|
@@ -10,7 +10,7 @@ import ora from 'ora';
|
|
|
10
10
|
import * as fs from 'fs';
|
|
11
11
|
import { createRequire } from 'module';
|
|
12
12
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
13
|
-
import { classifyOpenSpecDir, storePointerProblem } from './project-config.js';
|
|
13
|
+
import { classifyOpenSpecDir, MAX_CONTEXT_SIZE, readProjectConfig, storePointerProblem, } from './project-config.js';
|
|
14
14
|
import { findRepoPlanningRootSync } from './planning-home.js';
|
|
15
15
|
import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js';
|
|
16
16
|
import { AI_TOOLS, OPENSPEC_DIR_NAME, resolveToolIdAlias, } from './config.js';
|
|
@@ -33,6 +33,13 @@ const { version: OPENSPEC_VERSION } = require('../../package.json');
|
|
|
33
33
|
// Constants
|
|
34
34
|
// -----------------------------------------------------------------------------
|
|
35
35
|
const DEFAULT_SCHEMA = 'spec-driven';
|
|
36
|
+
function formatLanguageContext(language) {
|
|
37
|
+
return [
|
|
38
|
+
`Language: ${language}`,
|
|
39
|
+
`All artifacts must be written in ${language}.`,
|
|
40
|
+
'Keep OpenSpec structural headings and SHALL/MUST keywords in English.',
|
|
41
|
+
].join('\n');
|
|
42
|
+
}
|
|
36
43
|
const PROGRESS_SPINNER = {
|
|
37
44
|
interval: 80,
|
|
38
45
|
frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],
|
|
@@ -56,6 +63,7 @@ const WORKFLOW_TO_SKILL_DIR = {
|
|
|
56
63
|
// -----------------------------------------------------------------------------
|
|
57
64
|
export class InitCommand {
|
|
58
65
|
toolsArg;
|
|
66
|
+
language;
|
|
59
67
|
force;
|
|
60
68
|
interactiveOption;
|
|
61
69
|
profileOverride;
|
|
@@ -63,6 +71,7 @@ export class InitCommand {
|
|
|
63
71
|
copilotCloudOption;
|
|
64
72
|
constructor(options = {}) {
|
|
65
73
|
this.toolsArg = options.tools;
|
|
74
|
+
this.language = this.normalizeLanguage(options.language);
|
|
66
75
|
this.force = options.force ?? false;
|
|
67
76
|
this.interactiveOption = options.interactive;
|
|
68
77
|
this.profileOverride = options.profile;
|
|
@@ -97,6 +106,7 @@ export class InitCommand {
|
|
|
97
106
|
}
|
|
98
107
|
}
|
|
99
108
|
}
|
|
109
|
+
await this.assertLanguageCanBeApplied(projectPath, openspecPath);
|
|
100
110
|
// Check for legacy artifacts and handle cleanup
|
|
101
111
|
const deferredLegacyCleanup = await this.handleLegacyCleanup(projectPath, extendMode);
|
|
102
112
|
// Migrate OpenSpec-managed skills left in renamed tool directories
|
|
@@ -517,11 +527,31 @@ export class InitCommand {
|
|
|
517
527
|
}
|
|
518
528
|
validateTools(toolIds, toolStates, projectPath) {
|
|
519
529
|
const validatedTools = [];
|
|
520
|
-
const
|
|
521
|
-
|
|
530
|
+
const sharedAgentsTargets = ['codex', 'zed', 'agents'];
|
|
531
|
+
const selectedSharedTargets = sharedAgentsTargets.filter((toolId) => toolIds.includes(toolId));
|
|
532
|
+
// A Codex-rendered tree already serves Zed. Keep it when Zed is added later
|
|
533
|
+
// so Codex users do not lose the `$openspec-*` references they require.
|
|
534
|
+
const preserveConfiguredCodex = selectedSharedTargets.includes('zed') &&
|
|
535
|
+
toolStates.get('codex')?.configured;
|
|
536
|
+
const sharedTargetCandidates = preserveConfiguredCodex
|
|
537
|
+
? [...new Set([...selectedSharedTargets, 'codex'])]
|
|
538
|
+
: selectedSharedTargets;
|
|
539
|
+
const sharedTargetOwner = sharedTargetCandidates.includes('codex')
|
|
540
|
+
? 'codex'
|
|
541
|
+
: selectedSharedTargets.includes('zed')
|
|
542
|
+
? 'zed'
|
|
543
|
+
: selectedSharedTargets[0];
|
|
544
|
+
const firstSharedIndex = toolIds.findIndex((id) => sharedAgentsTargets.includes(id));
|
|
545
|
+
const reconciledToolIds = sharedTargetCandidates.length > 1
|
|
546
|
+
? toolIds.flatMap((toolId, index) => {
|
|
547
|
+
if (!sharedAgentsTargets.includes(toolId))
|
|
548
|
+
return [toolId];
|
|
549
|
+
return index === firstSharedIndex && sharedTargetOwner ? [sharedTargetOwner] : [];
|
|
550
|
+
})
|
|
522
551
|
: toolIds;
|
|
523
|
-
if (reconciledToolIds.length !== toolIds.length
|
|
524
|
-
|
|
552
|
+
if (reconciledToolIds.length !== toolIds.length ||
|
|
553
|
+
reconciledToolIds.some((toolId, index) => toolId !== toolIds[index])) {
|
|
554
|
+
console.log(chalk.dim(`Codex, Zed, and agents share .agents/skills; writing one tree for ${sharedTargetOwner}.`));
|
|
525
555
|
}
|
|
526
556
|
for (const toolId of reconciledToolIds) {
|
|
527
557
|
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
|
@@ -699,6 +729,53 @@ export class InitCommand {
|
|
|
699
729
|
// ═══════════════════════════════════════════════════════════
|
|
700
730
|
// CONFIG FILE
|
|
701
731
|
// ═══════════════════════════════════════════════════════════
|
|
732
|
+
normalizeLanguage(language) {
|
|
733
|
+
if (language === undefined)
|
|
734
|
+
return undefined;
|
|
735
|
+
const normalized = language.trim();
|
|
736
|
+
if (!normalized) {
|
|
737
|
+
throw new Error('The --language option requires a non-empty value.');
|
|
738
|
+
}
|
|
739
|
+
if (/\p{Cc}|\p{Bidi_Control}|[\u200B\u2028\u2029\uFEFF]/u.test(normalized)) {
|
|
740
|
+
throw new Error('The --language option must be a single line without control or invisible formatting characters.');
|
|
741
|
+
}
|
|
742
|
+
const serializedContext = `${formatLanguageContext(normalized)}\n`;
|
|
743
|
+
if (Buffer.byteLength(serializedContext, 'utf8') > MAX_CONTEXT_SIZE) {
|
|
744
|
+
throw new Error(`The --language option is too long for OpenSpec's ${MAX_CONTEXT_SIZE / 1024}KB project context limit.`);
|
|
745
|
+
}
|
|
746
|
+
return normalized;
|
|
747
|
+
}
|
|
748
|
+
languageContext() {
|
|
749
|
+
if (!this.language)
|
|
750
|
+
return undefined;
|
|
751
|
+
return formatLanguageContext(this.language);
|
|
752
|
+
}
|
|
753
|
+
async assertLanguageCanBeApplied(projectPath, openspecPath) {
|
|
754
|
+
const languageContext = this.languageContext();
|
|
755
|
+
if (!languageContext)
|
|
756
|
+
return;
|
|
757
|
+
const configPath = path.join(openspecPath, 'config.yaml');
|
|
758
|
+
const hasConfig = fs.existsSync(configPath) ||
|
|
759
|
+
fs.existsSync(path.join(openspecPath, 'config.yml'));
|
|
760
|
+
if (!hasConfig) {
|
|
761
|
+
try {
|
|
762
|
+
FileSystemUtils.assertProjectArtifactPath(projectPath, configPath);
|
|
763
|
+
}
|
|
764
|
+
catch (error) {
|
|
765
|
+
const reason = error instanceof Error ? `: ${error.message}` : '';
|
|
766
|
+
throw new Error(`Cannot create openspec/config.yaml for --language${reason}`);
|
|
767
|
+
}
|
|
768
|
+
if (!(await FileSystemUtils.canWriteFile(configPath))) {
|
|
769
|
+
throw new Error('Cannot create openspec/config.yaml for --language: the destination is not writable.');
|
|
770
|
+
}
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
const existingContext = readProjectConfig(projectPath)?.context;
|
|
774
|
+
if (existingContext?.includes(languageContext))
|
|
775
|
+
return;
|
|
776
|
+
throw new Error('--language does not overwrite an existing OpenSpec config. ' +
|
|
777
|
+
'Add the language instruction to its context field instead.');
|
|
778
|
+
}
|
|
702
779
|
async createConfig(openspecPath, extendMode) {
|
|
703
780
|
const configPath = path.join(openspecPath, 'config.yaml');
|
|
704
781
|
const configYmlPath = path.join(openspecPath, 'config.yml');
|
|
@@ -708,12 +785,19 @@ export class InitCommand {
|
|
|
708
785
|
return 'exists';
|
|
709
786
|
}
|
|
710
787
|
try {
|
|
711
|
-
const yamlContent = serializeConfig({
|
|
788
|
+
const yamlContent = serializeConfig({
|
|
789
|
+
schema: DEFAULT_SCHEMA,
|
|
790
|
+
context: this.languageContext(),
|
|
791
|
+
});
|
|
712
792
|
FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), configPath);
|
|
713
793
|
await FileSystemUtils.writeFile(configPath, yamlContent);
|
|
714
794
|
return 'created';
|
|
715
795
|
}
|
|
716
|
-
catch {
|
|
796
|
+
catch (error) {
|
|
797
|
+
if (this.language) {
|
|
798
|
+
const reason = error instanceof Error ? `: ${error.message}` : '';
|
|
799
|
+
throw new Error(`Failed to create openspec/config.yaml for --language${reason}`);
|
|
800
|
+
}
|
|
717
801
|
return 'skipped';
|
|
718
802
|
}
|
|
719
803
|
}
|
package/dist/core/profiles.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export type CoreWorkflowId = (typeof CORE_WORKFLOWS)[number];
|
|
|
20
20
|
* Resolves which workflows should be active for a given profile configuration.
|
|
21
21
|
*
|
|
22
22
|
* - 'core' profile always returns CORE_WORKFLOWS
|
|
23
|
-
* - 'custom' profile returns the provided customWorkflows
|
|
23
|
+
* - 'custom' profile returns the provided customWorkflows and required dependencies
|
|
24
24
|
*/
|
|
25
25
|
export declare function getProfileWorkflows(profile: Profile, customWorkflows?: string[]): readonly string[];
|
|
26
26
|
//# sourceMappingURL=profiles.d.ts.map
|
package/dist/core/profiles.js
CHANGED
|
@@ -30,11 +30,20 @@ export const ALL_WORKFLOWS = [
|
|
|
30
30
|
* Resolves which workflows should be active for a given profile configuration.
|
|
31
31
|
*
|
|
32
32
|
* - 'core' profile always returns CORE_WORKFLOWS
|
|
33
|
-
* - 'custom' profile returns the provided customWorkflows
|
|
33
|
+
* - 'custom' profile returns the provided customWorkflows and required dependencies
|
|
34
34
|
*/
|
|
35
35
|
export function getProfileWorkflows(profile, customWorkflows) {
|
|
36
36
|
if (profile === 'custom') {
|
|
37
|
-
|
|
37
|
+
const workflows = customWorkflows ?? [];
|
|
38
|
+
const syncDependentIndex = workflows.findIndex((workflow) => workflow === 'archive' || workflow === 'bulk-archive');
|
|
39
|
+
if (syncDependentIndex !== -1 && !workflows.includes('sync')) {
|
|
40
|
+
return [
|
|
41
|
+
...workflows.slice(0, syncDependentIndex),
|
|
42
|
+
'sync',
|
|
43
|
+
...workflows.slice(syncDependentIndex),
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
return workflows;
|
|
38
47
|
}
|
|
39
48
|
return CORE_WORKFLOWS;
|
|
40
49
|
}
|
|
@@ -39,6 +39,7 @@ export function getFeedbackSkillTemplate() {
|
|
|
39
39
|
5. **Submit on confirmation**
|
|
40
40
|
- Use the \`openspec feedback\` command to submit
|
|
41
41
|
- Format: \`openspec feedback "title" --body "body content"\`
|
|
42
|
+
- The command preserves the title text in the issue body and shortens long GitHub issue titles
|
|
42
43
|
- The command will automatically add metadata (version, platform, timestamp)
|
|
43
44
|
|
|
44
45
|
**Example Draft**
|
|
@@ -374,12 +374,12 @@ Here are the implementation tasks:
|
|
|
374
374
|
|
|
375
375
|
## 1. [Category or file]
|
|
376
376
|
|
|
377
|
-
- [ ] 1.1 [Specific task]
|
|
378
|
-
- [ ] 1.2 [Specific task]
|
|
377
|
+
- [ ] 1.1 [Specific task] — verify: [test, command, observable behavior, or delivered artifact]
|
|
378
|
+
- [ ] 1.2 [Specific task] — verify: [test, command, observable behavior, or delivered artifact]
|
|
379
379
|
|
|
380
|
-
## 2.
|
|
380
|
+
## 2. Integration Verification
|
|
381
381
|
|
|
382
|
-
- [ ] 2.1 [
|
|
382
|
+
- [ ] 2.1 Verify [broader integration or system behavior] with [end-to-end test or observable result]
|
|
383
383
|
|
|
384
384
|
---
|
|
385
385
|
|
package/dist/core/update.js
CHANGED
|
@@ -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
|
}
|
|
@@ -787,7 +798,7 @@ export class UpdateCommand {
|
|
|
787
798
|
return { newlyConfiguredTools: [], workflowOverrides: {} };
|
|
788
799
|
}
|
|
789
800
|
}
|
|
790
|
-
const inferredCodexWorkflows = getLegacyWorkflowIdsForTool(detection, 'codex');
|
|
801
|
+
const inferredCodexWorkflows = getProfileWorkflows('custom', getLegacyWorkflowIdsForTool(detection, 'codex')).filter((workflow) => ALL_WORKFLOWS.includes(workflow));
|
|
791
802
|
// Create skills/commands for selected tools using effective profile+delivery.
|
|
792
803
|
const newlyConfigured = [];
|
|
793
804
|
const skippedSharedSkillTools = [];
|
package/dist/telemetry/index.js
CHANGED
|
@@ -174,8 +174,9 @@ export async function maybeShowTelemetryNotice(options = {}) {
|
|
|
174
174
|
if (options.silent) {
|
|
175
175
|
return;
|
|
176
176
|
}
|
|
177
|
-
// Display notice
|
|
178
|
-
|
|
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');
|
|
179
180
|
// Mark as seen
|
|
180
181
|
await updateTelemetryConfig({ noticeSeen: true });
|
|
181
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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fission-ai/openspec",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "AI-native system for spec-driven development",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openspec",
|
|
@@ -33,7 +33,6 @@
|
|
|
33
33
|
"dist",
|
|
34
34
|
"bin",
|
|
35
35
|
"schemas",
|
|
36
|
-
"scripts/postinstall.js",
|
|
37
36
|
"!dist/**/*.test.js",
|
|
38
37
|
"!dist/**/__tests__",
|
|
39
38
|
"!dist/**/*.map"
|
|
@@ -53,8 +52,8 @@
|
|
|
53
52
|
"vitest": "^3.2.6"
|
|
54
53
|
},
|
|
55
54
|
"dependencies": {
|
|
56
|
-
"@inquirer/core": "^
|
|
57
|
-
"@inquirer/prompts": "^
|
|
55
|
+
"@inquirer/core": "^11.2.1",
|
|
56
|
+
"@inquirer/prompts": "^8.5.2",
|
|
58
57
|
"chalk": "^5.6.2",
|
|
59
58
|
"commander": "^14.0.0",
|
|
60
59
|
"cross-spawn": "7.0.6",
|
|
@@ -74,8 +73,6 @@
|
|
|
74
73
|
"test:watch": "vitest",
|
|
75
74
|
"test:ui": "vitest --ui",
|
|
76
75
|
"test:coverage": "vitest --coverage",
|
|
77
|
-
"test:postinstall": "node scripts/postinstall.js",
|
|
78
|
-
"postinstall": "node scripts/postinstall.js",
|
|
79
76
|
"check:pack-version": "node scripts/pack-version-check.mjs",
|
|
80
77
|
"release": "pnpm run release:ci",
|
|
81
78
|
"release:ci": "pnpm run check:pack-version && pnpm exec changeset publish",
|