@fission-ai/openspec 1.7.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.
- package/README.md +3 -1
- package/dist/cli/index.d.ts +15 -0
- package/dist/cli/index.js +39 -5
- package/dist/commands/change.js +16 -1
- package/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.js +5 -1
- package/dist/commands/schema.js +281 -47
- package/dist/commands/spec.js +30 -6
- package/dist/commands/validate.d.ts +23 -0
- package/dist/commands/validate.js +148 -4
- package/dist/commands/workflow/instructions.js +32 -27
- package/dist/commands/workflow/schemas.d.ts +2 -0
- package/dist/commands/workflow/schemas.js +9 -2
- package/dist/commands/workflow/status.js +2 -2
- package/dist/commands/workflow/templates.js +15 -5
- package/dist/core/archive.d.ts +14 -0
- package/dist/core/archive.js +1272 -151
- package/dist/core/artifact-graph/index.d.ts +1 -1
- package/dist/core/artifact-graph/index.js +1 -1
- package/dist/core/artifact-graph/instruction-loader.d.ts +3 -1
- package/dist/core/artifact-graph/instruction-loader.js +15 -5
- package/dist/core/artifact-graph/outputs.d.ts +1 -0
- package/dist/core/artifact-graph/outputs.js +67 -5
- package/dist/core/artifact-graph/resolver.d.ts +0 -12
- package/dist/core/artifact-graph/resolver.js +49 -9
- package/dist/core/artifact-graph/types.js +21 -3
- package/dist/core/available-tools.d.ts +2 -2
- package/dist/core/available-tools.js +14 -3
- package/dist/core/change-metadata/schema.d.ts +1 -0
- package/dist/core/change-metadata/schema.js +7 -0
- package/dist/core/change-status-policy.js +2 -2
- 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/registry.js +2 -0
- package/dist/core/completions/command-registry.js +13 -0
- package/dist/core/config-schema.d.ts +3 -0
- package/dist/core/config-schema.js +28 -1
- package/dist/core/config.d.ts +4 -0
- package/dist/core/config.js +40 -17
- package/dist/core/file-state.js +56 -24
- package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
- package/dist/core/github-copilot/cloud-agent.js +497 -0
- package/dist/core/global-config.d.ts +11 -0
- package/dist/core/init.d.ts +23 -0
- package/dist/core/init.js +253 -46
- package/dist/core/legacy-cleanup.d.ts +10 -0
- package/dist/core/legacy-cleanup.js +52 -28
- package/dist/core/migration.d.ts +6 -4
- package/dist/core/migration.js +78 -22
- package/dist/core/parsers/requirement-blocks.d.ts +10 -0
- package/dist/core/parsers/requirement-blocks.js +93 -1
- package/dist/core/parsers/requirement-text.d.ts +8 -0
- package/dist/core/parsers/requirement-text.js +4 -3
- package/dist/core/parsers/spec-structure.d.ts +1 -1
- package/dist/core/parsers/spec-structure.js +17 -1
- package/dist/core/profile-sync-drift.js +40 -5
- package/dist/core/project-config.d.ts +3 -0
- package/dist/core/project-config.js +31 -1
- package/dist/core/root-selection.d.ts +1 -1
- package/dist/core/shared/index.d.ts +1 -0
- package/dist/core/shared/index.js +1 -0
- package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
- package/dist/core/shared/skill-content-equivalence.js +41 -0
- package/dist/core/shared/skill-paths.d.ts +13 -0
- package/dist/core/shared/skill-paths.js +23 -0
- package/dist/core/shared/tool-detection.js +66 -35
- package/dist/core/shared-skill-target.d.ts +31 -0
- package/dist/core/shared-skill-target.js +186 -0
- package/dist/core/specs-apply.d.ts +72 -0
- package/dist/core/specs-apply.js +459 -59
- package/dist/core/templates/skill-templates.d.ts +1 -1
- package/dist/core/templates/skill-templates.js +1 -1
- package/dist/core/templates/workflows/apply-change.d.ts +10 -0
- package/dist/core/templates/workflows/apply-change.js +25 -182
- package/dist/core/templates/workflows/archive-change.js +8 -4
- package/dist/core/templates/workflows/bulk-archive-change.js +30 -26
- package/dist/core/templates/workflows/continue-change.js +6 -6
- package/dist/core/templates/workflows/explore.js +42 -18
- package/dist/core/templates/workflows/onboard.js +9 -3
- package/dist/core/templates/workflows/propose.js +72 -20
- 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/sync-specs.js +96 -18
- package/dist/core/templates/workflows/update-change.js +10 -8
- package/dist/core/update.d.ts +1 -0
- package/dist/core/update.js +136 -30
- 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 +26 -3
- package/dist/core/validation/validator.js +189 -19
- package/dist/core/version-check.js +6 -11
- package/dist/telemetry/config.d.ts +3 -4
- package/dist/telemetry/index.d.ts +12 -5
- package/dist/telemetry/index.js +27 -9
- package/dist/utils/change-metadata.d.ts +17 -4
- package/dist/utils/change-metadata.js +26 -4
- package/dist/utils/ci.d.ts +12 -0
- package/dist/utils/ci.js +16 -0
- package/dist/utils/command-references.d.ts +14 -2
- package/dist/utils/command-references.js +45 -3
- package/dist/utils/file-system.d.ts +10 -0
- package/dist/utils/file-system.js +70 -0
- package/dist/utils/interactive.d.ts +52 -0
- package/dist/utils/interactive.js +135 -0
- package/dist/utils/spec-discovery.d.ts +4 -4
- package/dist/utils/spec-discovery.js +19 -6
- package/dist/utils/task-progress.d.ts +51 -1
- package/dist/utils/task-progress.js +93 -41
- package/package.json +1 -1
- package/schemas/spec-driven/schema.yaml +8 -6
- package/schemas/spec-driven/templates/proposal.md +6 -4
|
@@ -2,7 +2,7 @@ export { ArtifactSchema, SchemaYamlSchema, type Artifact, type SchemaYaml, type
|
|
|
2
2
|
export { loadSchema, parseSchema, SchemaValidationError } from './schema.js';
|
|
3
3
|
export { ArtifactGraph } from './graph.js';
|
|
4
4
|
export { detectCompleted } from './state.js';
|
|
5
|
-
export { artifactOutputExists, isGlobPattern, resolveArtifactOutputs } from './outputs.js';
|
|
5
|
+
export { artifactOutputExists, isGlobPattern, resolveArtifactOutputPath, resolveArtifactOutputs, } from './outputs.js';
|
|
6
6
|
export { resolveSchema, listSchemas, listSchemasWithInfo, getSchemaDir, getPackageSchemasDir, getUserSchemasDir, SchemaLoadError, type SchemaInfo, } from './resolver.js';
|
|
7
7
|
export { loadTemplate, loadChangeContext, generateInstructions, formatChangeStatus, TemplateLoadError, type ChangeContext, type LoadChangeContextOptions, type ArtifactInstructions, type DependencyInfo, type ArtifactStatus, type ChangeStatus, type ArtifactPathSummary, } from './instruction-loader.js';
|
|
8
8
|
export type { PlanningHomeSummary, ActionContext, } from '../change-status-policy.js';
|
|
@@ -6,7 +6,7 @@ export { loadSchema, parseSchema, SchemaValidationError } from './schema.js';
|
|
|
6
6
|
export { ArtifactGraph } from './graph.js';
|
|
7
7
|
// State detection
|
|
8
8
|
export { detectCompleted } from './state.js';
|
|
9
|
-
export { artifactOutputExists, isGlobPattern, resolveArtifactOutputs } from './outputs.js';
|
|
9
|
+
export { artifactOutputExists, isGlobPattern, resolveArtifactOutputPath, resolveArtifactOutputs, } from './outputs.js';
|
|
10
10
|
// Schema resolution
|
|
11
11
|
export { resolveSchema, listSchemas, listSchemasWithInfo, getSchemaDir, getPackageSchemasDir, getUserSchemasDir, SchemaLoadError, } from './resolver.js';
|
|
12
12
|
// Instruction loading
|
|
@@ -144,7 +144,9 @@ export interface ChangeStatus {
|
|
|
144
144
|
nextSteps: string[];
|
|
145
145
|
/** Machine-readable action constraints for agents */
|
|
146
146
|
actionContext: ActionContext;
|
|
147
|
-
/** Whether all artifacts are complete */
|
|
147
|
+
/** Whether all planning artifacts are complete */
|
|
148
|
+
isPlanningComplete: boolean;
|
|
149
|
+
/** Compatibility alias for isPlanningComplete */
|
|
148
150
|
isComplete: boolean;
|
|
149
151
|
/** Artifact IDs required before apply phase (from schema's apply.requires) */
|
|
150
152
|
applyRequires: string[];
|
|
@@ -3,7 +3,7 @@ import * as path from 'node:path';
|
|
|
3
3
|
import { getSchemaDir, resolveSchema, listSchemasWithInfo } from './resolver.js';
|
|
4
4
|
import { ArtifactGraph } from './graph.js';
|
|
5
5
|
import { detectCompleted } from './state.js';
|
|
6
|
-
import { resolveArtifactOutputs } from './outputs.js';
|
|
6
|
+
import { resolveArtifactOutputPath, resolveArtifactOutputs } from './outputs.js';
|
|
7
7
|
import { readChangeMetadata, resolveSchemaForChange } from '../../utils/change-metadata.js';
|
|
8
8
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
9
9
|
import { buildActionContext, buildNextSteps, summarizePlanningHome, } from '../change-status-policy.js';
|
|
@@ -42,7 +42,14 @@ export function loadTemplate(schemaName, templatePath, projectRoot) {
|
|
|
42
42
|
if (!schemaDir) {
|
|
43
43
|
throw new TemplateLoadError(`Schema '${schemaName}' not found`, templatePath);
|
|
44
44
|
}
|
|
45
|
-
const
|
|
45
|
+
const templatesDir = path.join(schemaDir, 'templates');
|
|
46
|
+
const templatePathOnDisk = path.join(templatesDir, templatePath);
|
|
47
|
+
try {
|
|
48
|
+
FileSystemUtils.assertPathWithin(templatesDir, templatePathOnDisk);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
throw new TemplateLoadError(error instanceof Error ? error.message : String(error), templatePathOnDisk);
|
|
52
|
+
}
|
|
46
53
|
if (!fs.existsSync(templatePathOnDisk)) {
|
|
47
54
|
throw new TemplateLoadError(`Template not found: ${templatePathOnDisk}`, templatePathOnDisk);
|
|
48
55
|
}
|
|
@@ -144,7 +151,9 @@ export function generateInstructions(context, artifactId, projectRoot, options =
|
|
|
144
151
|
}
|
|
145
152
|
// Extract context and rules as separate fields (not prepended to template)
|
|
146
153
|
const configContext = projectConfig?.context?.trim() || undefined;
|
|
147
|
-
const rulesForArtifact = projectConfig?.rules
|
|
154
|
+
const rulesForArtifact = projectConfig?.rules && Object.hasOwn(projectConfig.rules, artifactId)
|
|
155
|
+
? projectConfig.rules[artifactId]
|
|
156
|
+
: undefined;
|
|
148
157
|
const configRules = rulesForArtifact && rulesForArtifact.length > 0 ? rulesForArtifact : undefined;
|
|
149
158
|
return {
|
|
150
159
|
changeName: context.changeName,
|
|
@@ -153,7 +162,7 @@ export function generateInstructions(context, artifactId, projectRoot, options =
|
|
|
153
162
|
changeDir: context.changeDir,
|
|
154
163
|
planningHome: summarizePlanningHome(context.planningHome),
|
|
155
164
|
outputPath: artifact.generates,
|
|
156
|
-
resolvedOutputPath:
|
|
165
|
+
resolvedOutputPath: resolveArtifactOutputPath(context.changeDir, artifact.generates),
|
|
157
166
|
existingOutputPaths: resolveArtifactOutputs(context.changeDir, artifact.generates),
|
|
158
167
|
description: artifact.description,
|
|
159
168
|
instruction: artifact.instruction,
|
|
@@ -216,7 +225,7 @@ export function formatChangeStatus(context, options = {}) {
|
|
|
216
225
|
const artifactStatuses = artifacts.map(artifact => {
|
|
217
226
|
artifactPaths[artifact.id] = {
|
|
218
227
|
outputPath: artifact.generates,
|
|
219
|
-
resolvedOutputPath:
|
|
228
|
+
resolvedOutputPath: resolveArtifactOutputPath(context.changeDir, artifact.generates),
|
|
220
229
|
existingOutputPaths: resolveArtifactOutputs(context.changeDir, artifact.generates),
|
|
221
230
|
};
|
|
222
231
|
if (context.skippedArtifacts?.has(artifact.id)) {
|
|
@@ -263,6 +272,7 @@ export function formatChangeStatus(context, options = {}) {
|
|
|
263
272
|
planningHome: summarizePlanningHome(context.planningHome),
|
|
264
273
|
changeRoot: context.changeDir,
|
|
265
274
|
artifactPaths,
|
|
275
|
+
isPlanningComplete: isComplete,
|
|
266
276
|
isComplete,
|
|
267
277
|
applyRequires,
|
|
268
278
|
nextSteps: buildNextSteps({
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Checks if a path contains glob pattern characters.
|
|
3
3
|
*/
|
|
4
4
|
export declare function isGlobPattern(pattern: string): boolean;
|
|
5
|
+
export declare function resolveArtifactOutputPath(changeDir: string, generates: string): string;
|
|
5
6
|
/**
|
|
6
7
|
* Resolves an artifact's output path(s) to concrete files that currently exist.
|
|
7
8
|
* Returns absolute file paths. Glob matches are sorted for deterministic output.
|
|
@@ -8,16 +8,66 @@ import { FileSystemUtils } from '../../utils/file-system.js';
|
|
|
8
8
|
export function isGlobPattern(pattern) {
|
|
9
9
|
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
|
|
10
10
|
}
|
|
11
|
+
export function resolveArtifactOutputPath(changeDir, generates) {
|
|
12
|
+
const outputPath = path.join(changeDir, generates);
|
|
13
|
+
FileSystemUtils.assertPathWithin(changeDir, outputPath);
|
|
14
|
+
return outputPath;
|
|
15
|
+
}
|
|
16
|
+
function assertGlobDirectoryTraversal(changeDir, currentDir, directorySegments, segmentIndex = 0, visited = new Set(), canonicalChangeDir = FileSystemUtils.canonicalizeExistingPath(changeDir), ancestors = new Set()) {
|
|
17
|
+
if (segmentIndex >= directorySegments.length)
|
|
18
|
+
return;
|
|
19
|
+
const canonicalDir = FileSystemUtils.canonicalizeExistingPath(currentDir);
|
|
20
|
+
FileSystemUtils.assertPathWithin(canonicalChangeDir, canonicalDir);
|
|
21
|
+
const visitKey = `${canonicalDir}\0${segmentIndex}`;
|
|
22
|
+
if (ancestors.has(visitKey)) {
|
|
23
|
+
throw new Error(`Cannot resolve artifact outputs through a linked directory cycle: ${currentDir}`);
|
|
24
|
+
}
|
|
25
|
+
if (visited.has(visitKey))
|
|
26
|
+
return;
|
|
27
|
+
visited.add(visitKey);
|
|
28
|
+
ancestors.add(visitKey);
|
|
29
|
+
try {
|
|
30
|
+
const segment = directorySegments[segmentIndex];
|
|
31
|
+
if (segment === '**') {
|
|
32
|
+
// `**` may consume no directory at all.
|
|
33
|
+
assertGlobDirectoryTraversal(changeDir, canonicalDir, directorySegments, segmentIndex + 1, visited, canonicalChangeDir, ancestors);
|
|
34
|
+
}
|
|
35
|
+
const matches = fg.sync(segment === '**' ? '*' : segment, {
|
|
36
|
+
cwd: canonicalDir,
|
|
37
|
+
onlyFiles: false,
|
|
38
|
+
followSymbolicLinks: false,
|
|
39
|
+
deep: 1,
|
|
40
|
+
});
|
|
41
|
+
for (const match of matches) {
|
|
42
|
+
const candidate = path.join(canonicalDir, match);
|
|
43
|
+
try {
|
|
44
|
+
if (!fs.statSync(candidate).isDirectory())
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error.code === 'ENOENT')
|
|
49
|
+
continue;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
const canonicalCandidate = FileSystemUtils.canonicalizeExistingPath(candidate);
|
|
53
|
+
FileSystemUtils.assertPathWithin(canonicalChangeDir, canonicalCandidate);
|
|
54
|
+
assertGlobDirectoryTraversal(changeDir, canonicalCandidate, directorySegments, segment === '**' ? segmentIndex : segmentIndex + 1, visited, canonicalChangeDir, ancestors);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
ancestors.delete(visitKey);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
11
61
|
/**
|
|
12
62
|
* Resolves an artifact's output path(s) to concrete files that currently exist.
|
|
13
63
|
* Returns absolute file paths. Glob matches are sorted for deterministic output.
|
|
14
64
|
*/
|
|
15
65
|
export function resolveArtifactOutputs(changeDir, generates) {
|
|
66
|
+
const outputPath = resolveArtifactOutputPath(changeDir, generates);
|
|
16
67
|
if (!isGlobPattern(generates)) {
|
|
17
|
-
const fullPath = path.join(changeDir, generates);
|
|
18
68
|
try {
|
|
19
|
-
return fs.statSync(
|
|
20
|
-
? [FileSystemUtils.canonicalizeExistingPath(
|
|
69
|
+
return fs.statSync(outputPath).isFile()
|
|
70
|
+
? [FileSystemUtils.canonicalizeExistingPath(outputPath)]
|
|
21
71
|
: [];
|
|
22
72
|
}
|
|
23
73
|
catch {
|
|
@@ -25,9 +75,21 @@ export function resolveArtifactOutputs(changeDir, generates) {
|
|
|
25
75
|
}
|
|
26
76
|
}
|
|
27
77
|
const normalizedPattern = FileSystemUtils.toPosixPath(generates);
|
|
78
|
+
assertGlobDirectoryTraversal(changeDir, changeDir, normalizedPattern.split('/').slice(0, -1));
|
|
28
79
|
const matches = fg
|
|
29
|
-
.sync(normalizedPattern, {
|
|
30
|
-
|
|
80
|
+
.sync(normalizedPattern, {
|
|
81
|
+
cwd: changeDir,
|
|
82
|
+
onlyFiles: true,
|
|
83
|
+
absolute: true,
|
|
84
|
+
// Preserve existing support for linked artifact directories. Every
|
|
85
|
+
// concrete match is canonically confined below before it is returned.
|
|
86
|
+
followSymbolicLinks: true,
|
|
87
|
+
})
|
|
88
|
+
.map((match) => {
|
|
89
|
+
const normalizedMatch = path.normalize(match);
|
|
90
|
+
FileSystemUtils.assertPathWithin(changeDir, normalizedMatch);
|
|
91
|
+
return FileSystemUtils.canonicalizeExistingPath(normalizedMatch);
|
|
92
|
+
});
|
|
31
93
|
return Array.from(new Set(matches)).sort();
|
|
32
94
|
}
|
|
33
95
|
/**
|
|
@@ -23,18 +23,6 @@ export declare function getUserSchemasDir(): string;
|
|
|
23
23
|
* @returns The path to the project's schemas directory
|
|
24
24
|
*/
|
|
25
25
|
export declare function getProjectSchemasDir(projectRoot: string): string;
|
|
26
|
-
/**
|
|
27
|
-
* Determines whether a directory entry represents a schema directory candidate.
|
|
28
|
-
*
|
|
29
|
-
* Returns true for real directories and for symlinks whose target is a
|
|
30
|
-
* directory. `fs.Dirent.isDirectory()` reports the raw entry type, so a symlink
|
|
31
|
-
* (even one pointing at a directory) has `isDirectory() === false`; we
|
|
32
|
-
* dereference such entries via `fs.statSync` to admit symlinked schema dirs
|
|
33
|
-
* while still rejecting symlinks-to-files and broken/dangling symlinks.
|
|
34
|
-
*
|
|
35
|
-
* @param parentDir - The directory containing the entry
|
|
36
|
-
* @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })`
|
|
37
|
-
*/
|
|
38
26
|
export declare function isSchemaDir(parentDir: string, entry: fs.Dirent): boolean;
|
|
39
27
|
/**
|
|
40
28
|
* Resolves a schema name to its directory path.
|
|
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { getGlobalDataDir } from '../global-config.js';
|
|
5
|
+
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
5
6
|
import { parseSchema, SchemaValidationError } from './schema.js';
|
|
6
7
|
/**
|
|
7
8
|
* Error thrown when loading a schema fails.
|
|
@@ -51,7 +52,21 @@ export function getProjectSchemasDir(projectRoot) {
|
|
|
51
52
|
* @param parentDir - The directory containing the entry
|
|
52
53
|
* @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })`
|
|
53
54
|
*/
|
|
55
|
+
/**
|
|
56
|
+
* Directories `schema fork` creates transiently while swapping a fork into
|
|
57
|
+
* place: a staging copy (`.fork-staging-<rand>`, created via mkdtemp) and a
|
|
58
|
+
* backup of the previous destination (`<name>.fork-backup-<pid>-<ts>`). Either
|
|
59
|
+
* can briefly coexist with real schemas in the schemas dir, so discovery must
|
|
60
|
+
* never surface them. Real schema names are kebab-case (no dots), so excluding
|
|
61
|
+
* these dot-bearing temp names can never hide a legitimate schema.
|
|
62
|
+
*/
|
|
63
|
+
function isOwnedForkTempDir(name) {
|
|
64
|
+
return name.startsWith('.fork-staging-') || name.includes('.fork-backup-');
|
|
65
|
+
}
|
|
54
66
|
export function isSchemaDir(parentDir, entry) {
|
|
67
|
+
if (isOwnedForkTempDir(entry.name)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
55
70
|
if (entry.isDirectory()) {
|
|
56
71
|
return true;
|
|
57
72
|
}
|
|
@@ -67,6 +82,25 @@ export function isSchemaDir(parentDir, entry) {
|
|
|
67
82
|
}
|
|
68
83
|
return false;
|
|
69
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Returns a schema directory only when its schema file stays within that
|
|
87
|
+
* directory's canonical trust boundary. The directory itself may be a symlink;
|
|
88
|
+
* external user schema links are an intentionally supported workflow.
|
|
89
|
+
*/
|
|
90
|
+
function getSchemaCandidateDir(schemasDir, name) {
|
|
91
|
+
const schemaDir = path.join(schemasDir, name);
|
|
92
|
+
const schemaPath = path.join(schemaDir, 'schema.yaml');
|
|
93
|
+
if (!fs.existsSync(schemaPath)) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
FileSystemUtils.assertPathWithin(schemaDir, schemaPath);
|
|
98
|
+
return schemaDir;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
70
104
|
/**
|
|
71
105
|
* Resolves a schema name to its directory path.
|
|
72
106
|
*
|
|
@@ -83,24 +117,30 @@ export function isSchemaDir(parentDir, entry) {
|
|
|
83
117
|
* @returns The path to the schema directory, or null if not found
|
|
84
118
|
*/
|
|
85
119
|
export function getSchemaDir(name, projectRoot) {
|
|
120
|
+
if (name.length === 0 ||
|
|
121
|
+
name === '.' ||
|
|
122
|
+
name === '..' ||
|
|
123
|
+
/[\\/]/u.test(name) ||
|
|
124
|
+
/^[A-Za-z]:/u.test(name) ||
|
|
125
|
+
path.posix.isAbsolute(name) ||
|
|
126
|
+
path.win32.isAbsolute(name)) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
86
129
|
// 1. Check project-local directory (if projectRoot provided)
|
|
87
130
|
if (projectRoot) {
|
|
88
|
-
const projectDir =
|
|
89
|
-
|
|
90
|
-
if (fs.existsSync(projectSchemaPath)) {
|
|
131
|
+
const projectDir = getSchemaCandidateDir(getProjectSchemasDir(projectRoot), name);
|
|
132
|
+
if (projectDir) {
|
|
91
133
|
return projectDir;
|
|
92
134
|
}
|
|
93
135
|
}
|
|
94
136
|
// 2. Check user override directory
|
|
95
|
-
const userDir =
|
|
96
|
-
|
|
97
|
-
if (fs.existsSync(userSchemaPath)) {
|
|
137
|
+
const userDir = getSchemaCandidateDir(getUserSchemasDir(), name);
|
|
138
|
+
if (userDir) {
|
|
98
139
|
return userDir;
|
|
99
140
|
}
|
|
100
141
|
// 3. Check package built-in directory
|
|
101
|
-
const packageDir =
|
|
102
|
-
|
|
103
|
-
if (fs.existsSync(packageSchemaPath)) {
|
|
142
|
+
const packageDir = getSchemaCandidateDir(getPackageSchemasDir(), name);
|
|
143
|
+
if (packageDir) {
|
|
104
144
|
return packageDir;
|
|
105
145
|
}
|
|
106
146
|
return null;
|
|
@@ -1,10 +1,28 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
1
2
|
import { z } from 'zod';
|
|
3
|
+
function relativePathSchema(fieldName) {
|
|
4
|
+
return z
|
|
5
|
+
.string()
|
|
6
|
+
.min(1, { error: `${fieldName} is required` })
|
|
7
|
+
.superRefine((value, ctx) => {
|
|
8
|
+
const segments = value.split(/[\\/]+/u);
|
|
9
|
+
const isDrivePath = /^[A-Za-z]:/u.test(value);
|
|
10
|
+
const isAbsolute = path.posix.isAbsolute(value) || path.win32.isAbsolute(value) || isDrivePath;
|
|
11
|
+
const escapes = segments.includes('..');
|
|
12
|
+
if (isAbsolute || escapes || value.includes('\0')) {
|
|
13
|
+
ctx.addIssue({
|
|
14
|
+
code: 'custom',
|
|
15
|
+
message: `${fieldName} must be a relative path inside its allowed directory`,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
2
20
|
// Artifact definition schema
|
|
3
21
|
export const ArtifactSchema = z.object({
|
|
4
22
|
id: z.string().min(1, { error: 'Artifact ID is required' }),
|
|
5
|
-
generates:
|
|
23
|
+
generates: relativePathSchema('generates field'),
|
|
6
24
|
description: z.string(),
|
|
7
|
-
template:
|
|
25
|
+
template: relativePathSchema('template field'),
|
|
8
26
|
instruction: z.string().optional(),
|
|
9
27
|
requires: z.array(z.string()).default([]),
|
|
10
28
|
});
|
|
@@ -13,7 +31,7 @@ export const ApplyPhaseSchema = z.object({
|
|
|
13
31
|
// Artifact IDs that must exist before apply is available
|
|
14
32
|
requires: z.array(z.string()).min(1, { error: 'At least one required artifact' }),
|
|
15
33
|
// Path to file with checkboxes for progress (relative to change dir), or null if no tracking
|
|
16
|
-
tracks:
|
|
34
|
+
tracks: relativePathSchema('apply.tracks').nullable().optional(),
|
|
17
35
|
// Custom guidance for the apply phase
|
|
18
36
|
instruction: z.string().optional(),
|
|
19
37
|
});
|
|
@@ -10,8 +10,8 @@ import { type AIToolOption } from './config.js';
|
|
|
10
10
|
* the tools that are present.
|
|
11
11
|
*
|
|
12
12
|
* For tools with `detectionPaths`, checks those specific paths (files or
|
|
13
|
-
* directories). Otherwise checks
|
|
14
|
-
* the
|
|
13
|
+
* directories). Otherwise checks the project's `skillsDir`, or managed skill
|
|
14
|
+
* files in the user's home directory for a global skill target.
|
|
15
15
|
*/
|
|
16
16
|
export declare function getAvailableTools(projectPath: string): AIToolOption[];
|
|
17
17
|
//# sourceMappingURL=available-tools.d.ts.map
|
|
@@ -7,16 +7,25 @@
|
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
import { AI_TOOLS } from './config.js';
|
|
10
|
+
import { reconcileSharedSkillTargets } from './shared-skill-target.js';
|
|
11
|
+
import { SKILL_NAMES } from './shared/tool-detection.js';
|
|
12
|
+
import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.js';
|
|
10
13
|
/**
|
|
11
14
|
* Scans the project path for AI tool configuration directories and returns
|
|
12
15
|
* the tools that are present.
|
|
13
16
|
*
|
|
14
17
|
* For tools with `detectionPaths`, checks those specific paths (files or
|
|
15
|
-
* directories). Otherwise checks
|
|
16
|
-
* the
|
|
18
|
+
* directories). Otherwise checks the project's `skillsDir`, or managed skill
|
|
19
|
+
* files in the user's home directory for a global skill target.
|
|
17
20
|
*/
|
|
18
21
|
export function getAvailableTools(projectPath) {
|
|
19
|
-
|
|
22
|
+
const available = AI_TOOLS.filter((tool) => {
|
|
23
|
+
if (!toolSupportsSkills(tool))
|
|
24
|
+
return false;
|
|
25
|
+
if (tool.globalSkillsDir) {
|
|
26
|
+
const skillsDir = resolveToolSkillsDir(projectPath, tool);
|
|
27
|
+
return SKILL_NAMES.some((skillName) => fs.existsSync(path.join(skillsDir, skillName, 'SKILL.md')));
|
|
28
|
+
}
|
|
20
29
|
if (!tool.skillsDir)
|
|
21
30
|
return false;
|
|
22
31
|
if (tool.detectionPaths && tool.detectionPaths.length > 0) {
|
|
@@ -39,5 +48,7 @@ export function getAvailableTools(projectPath) {
|
|
|
39
48
|
return false;
|
|
40
49
|
}
|
|
41
50
|
});
|
|
51
|
+
const activeProjectTools = new Set(reconcileSharedSkillTargets(projectPath, available.filter((tool) => tool.skillsDir)).map((tool) => tool.value));
|
|
52
|
+
return available.filter((tool) => tool.globalSkillsDir || activeProjectTools.has(tool.value));
|
|
42
53
|
}
|
|
43
54
|
//# sourceMappingURL=available-tools.js.map
|
|
@@ -15,6 +15,7 @@ export declare const ChangeMetadataSchema: z.ZodObject<{
|
|
|
15
15
|
id: z.ZodString;
|
|
16
16
|
}, z.core.$strict>>;
|
|
17
17
|
skip_specs: z.ZodOptional<z.ZodBoolean>;
|
|
18
|
+
retire_capabilities: z.ZodOptional<z.ZodBoolean>;
|
|
18
19
|
}, z.core.$strip>;
|
|
19
20
|
export type ChangeMetadata = z.infer<typeof ChangeMetadataSchema>;
|
|
20
21
|
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -32,5 +32,12 @@ export const ChangeMetadataSchema = z.object({
|
|
|
32
32
|
// complete - that path prefix, not the artifact id, is the contract custom
|
|
33
33
|
// schemas inherit.
|
|
34
34
|
skip_specs: z.boolean().optional(),
|
|
35
|
+
// Declares that this change may retire a capability: when its REMOVED entries
|
|
36
|
+
// take the last requirement a capability has, archive deletes that
|
|
37
|
+
// capability's main spec instead of aborting on a spec it could not write
|
|
38
|
+
// (#1302). Required because the deletion is not recoverable from the working
|
|
39
|
+
// tree - only from git - so it is the author's call, not an inference from the
|
|
40
|
+
// shape of a delta.
|
|
41
|
+
retire_capabilities: z.boolean().optional(),
|
|
35
42
|
});
|
|
36
43
|
//# sourceMappingURL=schema.js.map
|
|
@@ -23,12 +23,12 @@ export function buildActionContext(input) {
|
|
|
23
23
|
export function buildNextSteps(input) {
|
|
24
24
|
const readyArtifact = input.artifactStatuses.find((artifact) => artifact.status === 'ready');
|
|
25
25
|
const steps = [];
|
|
26
|
+
const storeFlag = input.storeId ? ` --store ${input.storeId}` : '';
|
|
26
27
|
if (readyArtifact) {
|
|
27
|
-
const storeFlag = input.storeId ? ` --store ${input.storeId}` : '';
|
|
28
28
|
steps.push(`Run openspec instructions ${readyArtifact.id} --change "${input.changeName}"${storeFlag} --json before writing that artifact.`);
|
|
29
29
|
}
|
|
30
30
|
else if (input.allArtifactsComplete) {
|
|
31
|
-
steps.push(
|
|
31
|
+
steps.push(`All planning artifacts are complete. Run openspec instructions apply --change "${input.changeName}"${storeFlag} --json to inspect implementation progress.`);
|
|
32
32
|
}
|
|
33
33
|
return steps;
|
|
34
34
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Code Command Adapter
|
|
3
|
+
*
|
|
4
|
+
* Command Code reads custom slash commands from `.commandcode/commands/`. The
|
|
5
|
+
* command name is the markdown filename without its `.md` extension, so
|
|
6
|
+
* `opsx-<id>.md` registers `/opsx-<id>` — the same flat naming Cursor and
|
|
7
|
+
* OpenCode use. See https://commandcode.ai/docs/reference/slash-commands.
|
|
8
|
+
*/
|
|
9
|
+
import type { ToolCommandAdapter } from '../types.js';
|
|
10
|
+
/**
|
|
11
|
+
* Command Code adapter for command generation.
|
|
12
|
+
* File path: .commandcode/commands/opsx-<id>.md
|
|
13
|
+
* Format: plain Markdown with $ARGUMENTS injected after the input contract
|
|
14
|
+
*
|
|
15
|
+
* Command Code executes the full trimmed file body and substitutes invocation
|
|
16
|
+
* arguments only where the body includes one of its argument placeholders.
|
|
17
|
+
*/
|
|
18
|
+
export declare const commandCodeAdapter: ToolCommandAdapter;
|
|
19
|
+
//# sourceMappingURL=command-code.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Code Command Adapter
|
|
3
|
+
*
|
|
4
|
+
* Command Code reads custom slash commands from `.commandcode/commands/`. The
|
|
5
|
+
* command name is the markdown filename without its `.md` extension, so
|
|
6
|
+
* `opsx-<id>.md` registers `/opsx-<id>` — the same flat naming Cursor and
|
|
7
|
+
* OpenCode use. See https://commandcode.ai/docs/reference/slash-commands.
|
|
8
|
+
*/
|
|
9
|
+
import path from 'path';
|
|
10
|
+
const COMMAND_CODE_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m;
|
|
11
|
+
function injectCommandCodeArgs(body) {
|
|
12
|
+
if (/^\*\*Provided arguments\*\*:\s*(?:\$(?:ARGUMENTS|@)|\$\{(?:ARGUMENTS|@)\})\s*$/m.test(body)) {
|
|
13
|
+
return body;
|
|
14
|
+
}
|
|
15
|
+
return body.replace(COMMAND_CODE_INPUT_HEADING, (heading) => `${heading}\n**Provided arguments**: $ARGUMENTS`);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Command Code adapter for command generation.
|
|
19
|
+
* File path: .commandcode/commands/opsx-<id>.md
|
|
20
|
+
* Format: plain Markdown with $ARGUMENTS injected after the input contract
|
|
21
|
+
*
|
|
22
|
+
* Command Code executes the full trimmed file body and substitutes invocation
|
|
23
|
+
* arguments only where the body includes one of its argument placeholders.
|
|
24
|
+
*/
|
|
25
|
+
export const commandCodeAdapter = {
|
|
26
|
+
toolId: 'command-code',
|
|
27
|
+
getFilePath(commandId) {
|
|
28
|
+
return path.join('.commandcode', 'commands', `opsx-${commandId}.md`);
|
|
29
|
+
},
|
|
30
|
+
formatFile(content) {
|
|
31
|
+
return `${injectCommandCodeArgs(content.body)}\n`;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=command-code.js.map
|
|
@@ -9,6 +9,7 @@ export { auggieAdapter } from './auggie.js';
|
|
|
9
9
|
export { bobAdapter } from './bob.js';
|
|
10
10
|
export { claudeAdapter } from './claude.js';
|
|
11
11
|
export { clineAdapter } from './cline.js';
|
|
12
|
+
export { commandCodeAdapter } from './command-code.js';
|
|
12
13
|
export { codebuddyAdapter } from './codebuddy.js';
|
|
13
14
|
export { continueAdapter } from './continue.js';
|
|
14
15
|
export { costrictAdapter } from './costrict.js';
|
|
@@ -9,6 +9,7 @@ export { auggieAdapter } from './auggie.js';
|
|
|
9
9
|
export { bobAdapter } from './bob.js';
|
|
10
10
|
export { claudeAdapter } from './claude.js';
|
|
11
11
|
export { clineAdapter } from './cline.js';
|
|
12
|
+
export { commandCodeAdapter } from './command-code.js';
|
|
12
13
|
export { codebuddyAdapter } from './codebuddy.js';
|
|
13
14
|
export { continueAdapter } from './continue.js';
|
|
14
15
|
export { costrictAdapter } from './costrict.js';
|
|
@@ -10,6 +10,7 @@ import { auggieAdapter } from './adapters/auggie.js';
|
|
|
10
10
|
import { bobAdapter } from './adapters/bob.js';
|
|
11
11
|
import { claudeAdapter } from './adapters/claude.js';
|
|
12
12
|
import { clineAdapter } from './adapters/cline.js';
|
|
13
|
+
import { commandCodeAdapter } from './adapters/command-code.js';
|
|
13
14
|
import { devinAdapter } from './adapters/devin.js';
|
|
14
15
|
import { codebuddyAdapter } from './adapters/codebuddy.js';
|
|
15
16
|
import { continueAdapter } from './adapters/continue.js';
|
|
@@ -45,6 +46,7 @@ export class CommandAdapterRegistry {
|
|
|
45
46
|
CommandAdapterRegistry.register(bobAdapter);
|
|
46
47
|
CommandAdapterRegistry.register(claudeAdapter);
|
|
47
48
|
CommandAdapterRegistry.register(clineAdapter);
|
|
49
|
+
CommandAdapterRegistry.register(commandCodeAdapter);
|
|
48
50
|
CommandAdapterRegistry.register(devinAdapter);
|
|
49
51
|
CommandAdapterRegistry.register(codebuddyAdapter);
|
|
50
52
|
CommandAdapterRegistry.register(continueAdapter);
|
|
@@ -26,6 +26,14 @@ export const COMMAND_REGISTRY = [
|
|
|
26
26
|
name: 'no-animation',
|
|
27
27
|
description: 'Show a static welcome screen instead of the animated one',
|
|
28
28
|
},
|
|
29
|
+
{
|
|
30
|
+
name: 'copilot-cloud',
|
|
31
|
+
description: 'Generate GitHub Copilot cloud coding-agent files (opt-in; default: prompt)',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'no-copilot-cloud',
|
|
35
|
+
description: 'Skip generating GitHub Copilot cloud coding-agent files',
|
|
36
|
+
},
|
|
29
37
|
],
|
|
30
38
|
},
|
|
31
39
|
{
|
|
@@ -89,6 +97,10 @@ export const COMMAND_REGISTRY = [
|
|
|
89
97
|
name: 'specs',
|
|
90
98
|
description: 'Validate all specs',
|
|
91
99
|
},
|
|
100
|
+
{
|
|
101
|
+
name: 'archived',
|
|
102
|
+
description: 'Validate that archived changes have all tasks completed (for pre-commit linting)',
|
|
103
|
+
},
|
|
92
104
|
COMMON_FLAGS.type,
|
|
93
105
|
COMMON_FLAGS.strict,
|
|
94
106
|
COMMON_FLAGS.jsonValidation,
|
|
@@ -218,6 +230,7 @@ export const COMMAND_REGISTRY = [
|
|
|
218
230
|
description: 'List available workflow schemas with descriptions',
|
|
219
231
|
flags: [
|
|
220
232
|
COMMON_FLAGS.json,
|
|
233
|
+
COMMON_FLAGS.store,
|
|
221
234
|
],
|
|
222
235
|
},
|
|
223
236
|
{
|
|
@@ -16,6 +16,9 @@ export declare const GlobalConfigSchema: z.ZodObject<{
|
|
|
16
16
|
}>>>;
|
|
17
17
|
workflows: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
18
18
|
defaultStore: z.ZodOptional<z.ZodString>;
|
|
19
|
+
telemetry: z.ZodOptional<z.ZodObject<{
|
|
20
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
21
|
+
}, z.core.$loose>>;
|
|
19
22
|
}, z.core.$loose>;
|
|
20
23
|
export type GlobalConfigType = z.infer<typeof GlobalConfigSchema>;
|
|
21
24
|
/**
|
|
@@ -24,6 +24,14 @@ export const GlobalConfigSchema = z
|
|
|
24
24
|
.string()
|
|
25
25
|
.optional()
|
|
26
26
|
.describe('Store id used as fallback root when no explicit --store, local root, or project-level store: pointer resolves'),
|
|
27
|
+
// passthrough keeps runtime-managed fields (anonymousId, noticeSeen) valid
|
|
28
|
+
// under CLI validate when users only set telemetry.enabled.
|
|
29
|
+
telemetry: z
|
|
30
|
+
.object({
|
|
31
|
+
enabled: z.boolean().optional(),
|
|
32
|
+
})
|
|
33
|
+
.passthrough()
|
|
34
|
+
.optional(),
|
|
27
35
|
})
|
|
28
36
|
.passthrough();
|
|
29
37
|
/**
|
|
@@ -34,7 +42,14 @@ export const DEFAULT_CONFIG = {
|
|
|
34
42
|
profile: 'core',
|
|
35
43
|
delivery: 'both',
|
|
36
44
|
};
|
|
37
|
-
const KNOWN_TOP_LEVEL_KEYS = new Set([
|
|
45
|
+
const KNOWN_TOP_LEVEL_KEYS = new Set([
|
|
46
|
+
...Object.keys(DEFAULT_CONFIG),
|
|
47
|
+
'workflows',
|
|
48
|
+
'defaultStore',
|
|
49
|
+
'telemetry',
|
|
50
|
+
]);
|
|
51
|
+
/** Nested keys users may set under `telemetry` via the CLI. */
|
|
52
|
+
const TELEMETRY_SETTABLE_KEYS = new Set(['enabled']);
|
|
38
53
|
/**
|
|
39
54
|
* Key segments that would reach the prototype chain instead of the config object.
|
|
40
55
|
* Never valid as configuration keys, so rejecting them costs nothing.
|
|
@@ -73,6 +88,18 @@ export function validateConfigKeyPath(path) {
|
|
|
73
88
|
}
|
|
74
89
|
return { valid: true };
|
|
75
90
|
}
|
|
91
|
+
if (rootKey === 'telemetry') {
|
|
92
|
+
if (rawKeys.length === 1) {
|
|
93
|
+
return { valid: false, reason: 'Set nested keys under telemetry (e.g. telemetry.enabled)' };
|
|
94
|
+
}
|
|
95
|
+
if (rawKeys.length !== 2 || !TELEMETRY_SETTABLE_KEYS.has(rawKeys[1])) {
|
|
96
|
+
return {
|
|
97
|
+
valid: false,
|
|
98
|
+
reason: `Unknown telemetry key "${rawKeys.slice(1).join('.')}" (allowed: enabled)`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return { valid: true };
|
|
102
|
+
}
|
|
76
103
|
if (rawKeys.length > 1) {
|
|
77
104
|
return { valid: false, reason: `"${rootKey}" does not support nested keys` };
|
|
78
105
|
}
|
package/dist/core/config.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export declare const OPENSPEC_DIR_NAME = "openspec";
|
|
2
|
+
export declare const OPENSPEC_SKILL_NAMES: readonly ["openspec-explore", "openspec-new-change", "openspec-continue-change", "openspec-apply-change", "openspec-update-change", "openspec-ff-change", "openspec-sync-specs", "openspec-archive-change", "openspec-bulk-archive-change", "openspec-verify-change", "openspec-onboard", "openspec-propose"];
|
|
2
3
|
export declare const OPENSPEC_MARKERS: {
|
|
3
4
|
start: string;
|
|
4
5
|
end: string;
|
|
@@ -12,8 +13,11 @@ export interface AIToolOption {
|
|
|
12
13
|
available: boolean;
|
|
13
14
|
successLabel?: string;
|
|
14
15
|
skillsDir?: string;
|
|
16
|
+
legacySkillsDirs?: string[];
|
|
17
|
+
globalSkillsDir?: string;
|
|
15
18
|
detectionPaths?: string[];
|
|
16
19
|
setupNote?: string;
|
|
20
|
+
requiresIdeRestart?: boolean;
|
|
17
21
|
}
|
|
18
22
|
export declare const AI_TOOLS: AIToolOption[];
|
|
19
23
|
/**
|