@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.
Files changed (113) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/index.d.ts +15 -0
  3. package/dist/cli/index.js +39 -5
  4. package/dist/commands/change.js +16 -1
  5. package/dist/commands/config.d.ts +5 -0
  6. package/dist/commands/config.js +5 -1
  7. package/dist/commands/schema.js +281 -47
  8. package/dist/commands/spec.js +30 -6
  9. package/dist/commands/validate.d.ts +23 -0
  10. package/dist/commands/validate.js +148 -4
  11. package/dist/commands/workflow/instructions.js +32 -27
  12. package/dist/commands/workflow/schemas.d.ts +2 -0
  13. package/dist/commands/workflow/schemas.js +9 -2
  14. package/dist/commands/workflow/status.js +2 -2
  15. package/dist/commands/workflow/templates.js +15 -5
  16. package/dist/core/archive.d.ts +14 -0
  17. package/dist/core/archive.js +1272 -151
  18. package/dist/core/artifact-graph/index.d.ts +1 -1
  19. package/dist/core/artifact-graph/index.js +1 -1
  20. package/dist/core/artifact-graph/instruction-loader.d.ts +3 -1
  21. package/dist/core/artifact-graph/instruction-loader.js +15 -5
  22. package/dist/core/artifact-graph/outputs.d.ts +1 -0
  23. package/dist/core/artifact-graph/outputs.js +67 -5
  24. package/dist/core/artifact-graph/resolver.d.ts +0 -12
  25. package/dist/core/artifact-graph/resolver.js +49 -9
  26. package/dist/core/artifact-graph/types.js +21 -3
  27. package/dist/core/available-tools.d.ts +2 -2
  28. package/dist/core/available-tools.js +14 -3
  29. package/dist/core/change-metadata/schema.d.ts +1 -0
  30. package/dist/core/change-metadata/schema.js +7 -0
  31. package/dist/core/change-status-policy.js +2 -2
  32. package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
  33. package/dist/core/command-generation/adapters/command-code.js +34 -0
  34. package/dist/core/command-generation/adapters/index.d.ts +1 -0
  35. package/dist/core/command-generation/adapters/index.js +1 -0
  36. package/dist/core/command-generation/registry.js +2 -0
  37. package/dist/core/completions/command-registry.js +13 -0
  38. package/dist/core/config-schema.d.ts +3 -0
  39. package/dist/core/config-schema.js +28 -1
  40. package/dist/core/config.d.ts +4 -0
  41. package/dist/core/config.js +40 -17
  42. package/dist/core/file-state.js +56 -24
  43. package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
  44. package/dist/core/github-copilot/cloud-agent.js +497 -0
  45. package/dist/core/global-config.d.ts +11 -0
  46. package/dist/core/init.d.ts +23 -0
  47. package/dist/core/init.js +253 -46
  48. package/dist/core/legacy-cleanup.d.ts +10 -0
  49. package/dist/core/legacy-cleanup.js +52 -28
  50. package/dist/core/migration.d.ts +6 -4
  51. package/dist/core/migration.js +78 -22
  52. package/dist/core/parsers/requirement-blocks.d.ts +10 -0
  53. package/dist/core/parsers/requirement-blocks.js +93 -1
  54. package/dist/core/parsers/requirement-text.d.ts +8 -0
  55. package/dist/core/parsers/requirement-text.js +4 -3
  56. package/dist/core/parsers/spec-structure.d.ts +1 -1
  57. package/dist/core/parsers/spec-structure.js +17 -1
  58. package/dist/core/profile-sync-drift.js +40 -5
  59. package/dist/core/project-config.d.ts +3 -0
  60. package/dist/core/project-config.js +31 -1
  61. package/dist/core/root-selection.d.ts +1 -1
  62. package/dist/core/shared/index.d.ts +1 -0
  63. package/dist/core/shared/index.js +1 -0
  64. package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
  65. package/dist/core/shared/skill-content-equivalence.js +41 -0
  66. package/dist/core/shared/skill-paths.d.ts +13 -0
  67. package/dist/core/shared/skill-paths.js +23 -0
  68. package/dist/core/shared/tool-detection.js +66 -35
  69. package/dist/core/shared-skill-target.d.ts +31 -0
  70. package/dist/core/shared-skill-target.js +186 -0
  71. package/dist/core/specs-apply.d.ts +72 -0
  72. package/dist/core/specs-apply.js +459 -59
  73. package/dist/core/templates/skill-templates.d.ts +1 -1
  74. package/dist/core/templates/skill-templates.js +1 -1
  75. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  76. package/dist/core/templates/workflows/apply-change.js +25 -182
  77. package/dist/core/templates/workflows/archive-change.js +8 -4
  78. package/dist/core/templates/workflows/bulk-archive-change.js +30 -26
  79. package/dist/core/templates/workflows/continue-change.js +6 -6
  80. package/dist/core/templates/workflows/explore.js +42 -18
  81. package/dist/core/templates/workflows/onboard.js +9 -3
  82. package/dist/core/templates/workflows/propose.js +72 -20
  83. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  84. package/dist/core/templates/workflows/store-selection.js +1 -1
  85. package/dist/core/templates/workflows/sync-specs.js +96 -18
  86. package/dist/core/templates/workflows/update-change.js +10 -8
  87. package/dist/core/update.d.ts +1 -0
  88. package/dist/core/update.js +136 -30
  89. package/dist/core/validation/task-numbering.d.ts +16 -0
  90. package/dist/core/validation/task-numbering.js +57 -0
  91. package/dist/core/validation/validator.d.ts +26 -3
  92. package/dist/core/validation/validator.js +189 -19
  93. package/dist/core/version-check.js +6 -11
  94. package/dist/telemetry/config.d.ts +3 -4
  95. package/dist/telemetry/index.d.ts +12 -5
  96. package/dist/telemetry/index.js +27 -9
  97. package/dist/utils/change-metadata.d.ts +17 -4
  98. package/dist/utils/change-metadata.js +26 -4
  99. package/dist/utils/ci.d.ts +12 -0
  100. package/dist/utils/ci.js +16 -0
  101. package/dist/utils/command-references.d.ts +14 -2
  102. package/dist/utils/command-references.js +45 -3
  103. package/dist/utils/file-system.d.ts +10 -0
  104. package/dist/utils/file-system.js +70 -0
  105. package/dist/utils/interactive.d.ts +52 -0
  106. package/dist/utils/interactive.js +135 -0
  107. package/dist/utils/spec-discovery.d.ts +4 -4
  108. package/dist/utils/spec-discovery.js +19 -6
  109. package/dist/utils/task-progress.d.ts +51 -1
  110. package/dist/utils/task-progress.js +93 -41
  111. package/package.json +1 -1
  112. package/schemas/spec-driven/schema.yaml +8 -6
  113. 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 templatePathOnDisk = path.join(schemaDir, 'templates', templatePath);
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?.[artifactId];
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: path.join(context.changeDir, artifact.generates),
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: path.join(context.changeDir, artifact.generates),
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(fullPath).isFile()
20
- ? [FileSystemUtils.canonicalizeExistingPath(fullPath)]
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, { cwd: changeDir, onlyFiles: true, absolute: true })
30
- .map((match) => FileSystemUtils.canonicalizeExistingPath(path.normalize(match)));
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 = path.join(getProjectSchemasDir(projectRoot), name);
89
- const projectSchemaPath = path.join(projectDir, 'schema.yaml');
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 = path.join(getUserSchemasDir(), name);
96
- const userSchemaPath = path.join(userDir, 'schema.yaml');
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 = path.join(getPackageSchemasDir(), name);
102
- const packageSchemaPath = path.join(packageDir, 'schema.yaml');
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: z.string().min(1, { error: 'generates field is required' }),
23
+ generates: relativePathSchema('generates field'),
6
24
  description: z.string(),
7
- template: z.string().min(1, { error: 'template field is required' }),
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: z.string().nullable().optional(),
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 for the tool's `skillsDir` directory at
14
- * the project root. Only tools with a `skillsDir` property are considered.
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 for the tool's `skillsDir` directory at
16
- * the project root. Only tools with a `skillsDir` property are considered.
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
- return AI_TOOLS.filter((tool) => {
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('All planning artifacts are complete; review tasks before implementation.');
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([...Object.keys(DEFAULT_CONFIG), 'workflows', 'defaultStore']);
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
  }
@@ -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
  /**