@fission-ai/openspec 1.7.0 → 1.8.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 (95) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/index.js +6 -1
  3. package/dist/commands/change.js +15 -1
  4. package/dist/commands/schema.js +89 -20
  5. package/dist/commands/spec.js +30 -6
  6. package/dist/commands/validate.js +2 -2
  7. package/dist/commands/workflow/instructions.js +32 -27
  8. package/dist/commands/workflow/status.js +2 -2
  9. package/dist/commands/workflow/templates.js +15 -5
  10. package/dist/core/archive.d.ts +14 -0
  11. package/dist/core/archive.js +1265 -151
  12. package/dist/core/artifact-graph/index.d.ts +1 -1
  13. package/dist/core/artifact-graph/index.js +1 -1
  14. package/dist/core/artifact-graph/instruction-loader.d.ts +3 -1
  15. package/dist/core/artifact-graph/instruction-loader.js +15 -5
  16. package/dist/core/artifact-graph/outputs.d.ts +1 -0
  17. package/dist/core/artifact-graph/outputs.js +67 -5
  18. package/dist/core/artifact-graph/resolver.js +35 -9
  19. package/dist/core/artifact-graph/types.js +21 -3
  20. package/dist/core/available-tools.d.ts +2 -2
  21. package/dist/core/available-tools.js +14 -3
  22. package/dist/core/change-metadata/schema.d.ts +1 -0
  23. package/dist/core/change-metadata/schema.js +7 -0
  24. package/dist/core/change-status-policy.js +2 -2
  25. package/dist/core/completions/command-registry.js +8 -0
  26. package/dist/core/config-schema.d.ts +3 -0
  27. package/dist/core/config-schema.js +28 -1
  28. package/dist/core/config.d.ts +3 -0
  29. package/dist/core/config.js +24 -2
  30. package/dist/core/file-state.js +56 -24
  31. package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
  32. package/dist/core/github-copilot/cloud-agent.js +497 -0
  33. package/dist/core/global-config.d.ts +11 -0
  34. package/dist/core/init.d.ts +23 -0
  35. package/dist/core/init.js +234 -39
  36. package/dist/core/legacy-cleanup.js +4 -2
  37. package/dist/core/migration.d.ts +6 -4
  38. package/dist/core/migration.js +78 -22
  39. package/dist/core/parsers/requirement-blocks.d.ts +10 -0
  40. package/dist/core/parsers/requirement-blocks.js +60 -0
  41. package/dist/core/parsers/spec-structure.d.ts +1 -1
  42. package/dist/core/parsers/spec-structure.js +17 -1
  43. package/dist/core/profile-sync-drift.js +40 -5
  44. package/dist/core/project-config.d.ts +3 -0
  45. package/dist/core/project-config.js +31 -1
  46. package/dist/core/shared/index.d.ts +1 -0
  47. package/dist/core/shared/index.js +1 -0
  48. package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
  49. package/dist/core/shared/skill-content-equivalence.js +41 -0
  50. package/dist/core/shared/skill-paths.d.ts +13 -0
  51. package/dist/core/shared/skill-paths.js +23 -0
  52. package/dist/core/shared/tool-detection.js +66 -35
  53. package/dist/core/shared-skill-target.d.ts +16 -0
  54. package/dist/core/shared-skill-target.js +156 -0
  55. package/dist/core/specs-apply.d.ts +72 -0
  56. package/dist/core/specs-apply.js +448 -55
  57. package/dist/core/templates/skill-templates.d.ts +1 -1
  58. package/dist/core/templates/skill-templates.js +1 -1
  59. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  60. package/dist/core/templates/workflows/apply-change.js +22 -182
  61. package/dist/core/templates/workflows/archive-change.js +8 -4
  62. package/dist/core/templates/workflows/bulk-archive-change.js +30 -26
  63. package/dist/core/templates/workflows/continue-change.js +6 -6
  64. package/dist/core/templates/workflows/explore.js +42 -18
  65. package/dist/core/templates/workflows/onboard.js +9 -3
  66. package/dist/core/templates/workflows/propose.js +72 -20
  67. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  68. package/dist/core/templates/workflows/store-selection.js +1 -1
  69. package/dist/core/templates/workflows/sync-specs.js +96 -18
  70. package/dist/core/templates/workflows/update-change.js +10 -8
  71. package/dist/core/update.d.ts +1 -0
  72. package/dist/core/update.js +103 -24
  73. package/dist/core/validation/validator.d.ts +23 -3
  74. package/dist/core/validation/validator.js +134 -18
  75. package/dist/core/version-check.js +6 -11
  76. package/dist/telemetry/config.d.ts +3 -4
  77. package/dist/telemetry/index.d.ts +9 -4
  78. package/dist/telemetry/index.js +20 -8
  79. package/dist/utils/change-metadata.d.ts +17 -4
  80. package/dist/utils/change-metadata.js +26 -4
  81. package/dist/utils/ci.d.ts +12 -0
  82. package/dist/utils/ci.js +16 -0
  83. package/dist/utils/command-references.d.ts +14 -2
  84. package/dist/utils/command-references.js +45 -3
  85. package/dist/utils/file-system.d.ts +10 -0
  86. package/dist/utils/file-system.js +70 -0
  87. package/dist/utils/interactive.d.ts +23 -0
  88. package/dist/utils/interactive.js +32 -0
  89. package/dist/utils/spec-discovery.d.ts +4 -4
  90. package/dist/utils/spec-discovery.js +19 -6
  91. package/dist/utils/task-progress.d.ts +17 -0
  92. package/dist/utils/task-progress.js +41 -13
  93. package/package.json +1 -1
  94. package/schemas/spec-driven/schema.yaml +8 -6
  95. package/schemas/spec-driven/templates/proposal.md +6 -4
package/README.md CHANGED
@@ -248,7 +248,9 @@ OpenSpec collects anonymous usage stats.
248
248
 
249
249
  We collect only command names and version to understand usage patterns. No arguments, paths, content, or PII. Automatically disabled in CI.
250
250
 
251
- **Opt-out:** `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1`
251
+ **Opt-out (any one is enough):**
252
+ - `openspec config set telemetry.enabled false` (global config; unset means on)
253
+ - `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1` (env overrides config)
252
254
 
253
255
  </details>
254
256
 
package/dist/cli/index.js CHANGED
@@ -97,7 +97,9 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
97
97
  program.hook('postAction', async () => {
98
98
  await shutdown();
99
99
  });
100
- const availableToolIds = AI_TOOLS.filter((tool) => tool.skillsDir).map((tool) => tool.value);
100
+ const availableToolIds = AI_TOOLS
101
+ .filter((tool) => tool.skillsDir || tool.globalSkillsDir)
102
+ .map((tool) => tool.value);
101
103
  const toolAliasNote = Object.entries(TOOL_ID_ALIASES)
102
104
  .map(([retired, current]) => `${retired} (now ${current})`)
103
105
  .join(', ');
@@ -109,6 +111,8 @@ program
109
111
  .option('--force', 'Auto-cleanup legacy files without prompting')
110
112
  .option('--profile <profile>', 'Override global config profile (core or custom)')
111
113
  .option('--no-animation', 'Show a static welcome screen instead of the animated one')
114
+ .option('--copilot-cloud', 'Set up GitHub Copilot cloud coding-agent files without prompting')
115
+ .option('--no-copilot-cloud', 'Skip GitHub Copilot cloud coding-agent files without prompting')
112
116
  .action(async (targetPath = '.', options) => {
113
117
  try {
114
118
  // Validate that the path is a valid directory
@@ -137,6 +141,7 @@ program
137
141
  force: options?.force,
138
142
  profile: options?.profile,
139
143
  animation: options?.animation,
144
+ copilotCloud: options?.copilotCloud,
140
145
  });
141
146
  await initCommand.execute(targetPath);
142
147
  }
@@ -7,6 +7,7 @@ import { ChangeParser } from '../core/parsers/change-parser.js';
7
7
  import { isInteractive } from '../utils/interactive.js';
8
8
  import { getActiveChangeIds } from '../utils/item-discovery.js';
9
9
  import { getTaskProgressForChange } from '../utils/task-progress.js';
10
+ import { FileSystemUtils } from '../utils/file-system.js';
10
11
  /**
11
12
  * True only when `target` is definitively absent. An EACCES or I/O failure
12
13
  * means existence cannot be determined, so callers fall through to their
@@ -95,12 +96,15 @@ export class ChangeCommand {
95
96
  }
96
97
  throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
97
98
  }
99
+ FileSystemUtils.assertPathWithin(path.dirname(proposalPath), proposalPath);
98
100
  if (options?.json) {
101
+ FileSystemUtils.assertPathWithin(changeDir, proposalPath);
99
102
  const jsonOutput = await this.converter.convertChangeToJson(proposalPath);
100
103
  if (options.requirementsOnly) {
101
104
  console.error('Flag --requirements-only is deprecated; use --deltas-only instead.');
102
105
  }
103
106
  const parsed = JSON.parse(jsonOutput);
107
+ FileSystemUtils.assertPathWithin(changeDir, proposalPath);
104
108
  const contentForTitle = await fs.readFile(proposalPath, 'utf-8');
105
109
  const title = this.extractTitle(contentForTitle, changeName);
106
110
  const id = parsed.name;
@@ -115,6 +119,7 @@ export class ChangeCommand {
115
119
  console.log(JSON.stringify(output, null, 2));
116
120
  }
117
121
  else {
122
+ FileSystemUtils.assertPathWithin(changeDir, proposalPath);
118
123
  const content = await fs.readFile(proposalPath, 'utf-8');
119
124
  console.log(content);
120
125
  }
@@ -147,6 +152,7 @@ export class ChangeCommand {
147
152
  return { id: changeName, title: changeName, deltaCount: 0, taskStatus };
148
153
  }
149
154
  try {
155
+ FileSystemUtils.assertPathWithin(changeDir, proposalPath);
150
156
  const content = await fs.readFile(proposalPath, 'utf-8');
151
157
  const parser = new ChangeParser(content, changeDir);
152
158
  const change = await parser.parseChangeWithDeltas(changeName);
@@ -186,6 +192,7 @@ export class ChangeCommand {
186
192
  continue;
187
193
  }
188
194
  try {
195
+ FileSystemUtils.assertPathWithin(changeDir, proposalPath);
189
196
  const content = await fs.readFile(proposalPath, 'utf-8');
190
197
  const title = this.extractTitle(content, changeName);
191
198
  const parser = new ChangeParser(content, changeDir);
@@ -225,6 +232,9 @@ export class ChangeCommand {
225
232
  }
226
233
  }
227
234
  const changeDir = path.join(changesPath, changeName);
235
+ if (!isChangeDirectoryName(changesPath, changeDir)) {
236
+ throw new Error(`Change "${changeName}" not found at ${changeDir}`);
237
+ }
228
238
  try {
229
239
  await fs.access(changeDir);
230
240
  }
@@ -232,7 +242,11 @@ export class ChangeCommand {
232
242
  throw new Error(`Change "${changeName}" not found at ${changeDir}`);
233
243
  }
234
244
  const validator = new Validator(options?.strict || false);
235
- const report = await validator.validateChangeDeltaSpecs(changeDir);
245
+ const report = await validator.validateChangeDeltaSpecs(changeDir, {
246
+ // Derived from changesPath so the main specs come from the same root the
247
+ // change itself was resolved against.
248
+ mainSpecsDir: path.join(path.dirname(changesPath), 'specs'),
249
+ });
236
250
  if (options?.json) {
237
251
  console.log(JSON.stringify(report, null, 2));
238
252
  }
@@ -4,6 +4,7 @@ import ora from 'ora';
4
4
  import { stringify as stringifyYaml } from 'yaml';
5
5
  import { getSchemaDir, getProjectSchemasDir, getUserSchemasDir, getPackageSchemasDir, isSchemaDir, listSchemas, } from '../core/artifact-graph/resolver.js';
6
6
  import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js';
7
+ import { FileSystemUtils } from '../utils/file-system.js';
7
8
  /**
8
9
  * Check all three locations for a schema and return which ones exist.
9
10
  */
@@ -129,21 +130,30 @@ function validateSchema(schemaDir, verbose = false) {
129
130
  }
130
131
  return { valid: false, issues };
131
132
  }
132
- // Check template files exist
133
- // Templates can be in schemaDir directly or in a templates/ subdirectory
133
+ // Check template files exist in the same directory used at runtime.
134
134
  if (verbose) {
135
135
  console.log(' Checking template files...');
136
136
  }
137
137
  for (const artifact of schema.artifacts) {
138
- // Try templates subdirectory first (standard location), then root
139
- const templatePathInTemplates = path.join(schemaDir, 'templates', artifact.template);
140
- const templatePathInRoot = path.join(schemaDir, artifact.template);
141
- if (!fs.existsSync(templatePathInTemplates) && !fs.existsSync(templatePathInRoot)) {
138
+ const templatesDir = path.join(schemaDir, 'templates');
139
+ const existingTemplatePath = path.join(templatesDir, artifact.template);
140
+ if (!fs.existsSync(existingTemplatePath)) {
142
141
  issues.push({
143
142
  level: 'error',
144
143
  path: `artifacts.${artifact.id}.template`,
145
144
  message: `Template file '${artifact.template}' not found for artifact '${artifact.id}'`,
146
145
  });
146
+ continue;
147
+ }
148
+ try {
149
+ FileSystemUtils.assertPathWithin(templatesDir, existingTemplatePath);
150
+ }
151
+ catch {
152
+ issues.push({
153
+ level: 'error',
154
+ path: `artifacts.${artifact.id}.template`,
155
+ message: `Template file '${artifact.template}' points outside the schema templates directory`,
156
+ });
147
157
  }
148
158
  }
149
159
  // Dependency graph validation is already done by parseSchema
@@ -162,19 +172,73 @@ function isValidSchemaName(name) {
162
172
  /**
163
173
  * Copy a directory recursively.
164
174
  */
165
- function copyDirRecursive(src, dest) {
175
+ function resolveSchemaCopyPath(allowedRoot, sourcePath) {
176
+ try {
177
+ const canonicalRoot = fs.realpathSync(allowedRoot);
178
+ const canonicalPath = fs.realpathSync(sourcePath);
179
+ FileSystemUtils.assertPathWithin(canonicalRoot, canonicalPath);
180
+ return canonicalPath;
181
+ }
182
+ catch (error) {
183
+ const detail = error instanceof Error ? error.message : String(error);
184
+ throw new Error(`Cannot fork schema with linked or unsupported entry: ${sourcePath}: ${detail}`, { cause: error });
185
+ }
186
+ }
187
+ function copyDirRecursive(src, dest, allowedRoot = src, ancestors = new Set()) {
188
+ const canonicalSrc = resolveSchemaCopyPath(allowedRoot, src);
189
+ if (ancestors.has(canonicalSrc)) {
190
+ throw new Error(`Cannot fork schema with a linked directory cycle: ${src}`);
191
+ }
192
+ ancestors.add(canonicalSrc);
166
193
  fs.mkdirSync(dest, { recursive: true });
167
- const entries = fs.readdirSync(src, { withFileTypes: true });
168
- for (const entry of entries) {
169
- const srcPath = path.join(src, entry.name);
170
- const destPath = path.join(dest, entry.name);
171
- if (entry.isDirectory()) {
172
- copyDirRecursive(srcPath, destPath);
194
+ try {
195
+ const entries = fs.readdirSync(src, { withFileTypes: true });
196
+ for (const entry of entries) {
197
+ const srcPath = path.join(src, entry.name);
198
+ const destPath = path.join(dest, entry.name);
199
+ const canonicalEntry = resolveSchemaCopyPath(allowedRoot, srcPath);
200
+ const stats = fs.statSync(canonicalEntry);
201
+ if (stats.isDirectory()) {
202
+ copyDirRecursive(canonicalEntry, destPath, allowedRoot, ancestors);
203
+ }
204
+ else if (stats.isFile()) {
205
+ // Dereference confined links so the fork is an independent schema.
206
+ fs.copyFileSync(canonicalEntry, destPath);
207
+ }
208
+ else {
209
+ throw new Error(`Cannot fork schema with linked or unsupported entry: ${srcPath}`);
210
+ }
173
211
  }
174
- else {
175
- fs.copyFileSync(srcPath, destPath);
212
+ }
213
+ finally {
214
+ ancestors.delete(canonicalSrc);
215
+ }
216
+ }
217
+ /**
218
+ * Verifies a schema tree before replacing or creating the fork destination.
219
+ */
220
+ function assertSchemaTreeCanBeCopied(src, allowedRoot = src, ancestors = new Set()) {
221
+ const canonicalSrc = resolveSchemaCopyPath(allowedRoot, src);
222
+ if (ancestors.has(canonicalSrc)) {
223
+ throw new Error(`Cannot fork schema with a linked directory cycle: ${src}`);
224
+ }
225
+ ancestors.add(canonicalSrc);
226
+ try {
227
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
228
+ const entryPath = path.join(src, entry.name);
229
+ const canonicalEntry = resolveSchemaCopyPath(allowedRoot, entryPath);
230
+ const stats = fs.statSync(canonicalEntry);
231
+ if (stats.isDirectory()) {
232
+ assertSchemaTreeCanBeCopied(canonicalEntry, allowedRoot, ancestors);
233
+ }
234
+ else if (!stats.isFile()) {
235
+ throw new Error(`Cannot fork schema with linked or unsupported entry: ${entryPath}`);
236
+ }
176
237
  }
177
238
  }
239
+ finally {
240
+ ancestors.delete(canonicalSrc);
241
+ }
178
242
  }
179
243
  /**
180
244
  * Default artifacts with descriptions for schema init.
@@ -378,9 +442,9 @@ export function registerSchemaCommand(program) {
378
442
  console.log(` ${issue.level}: ${issue.message}`);
379
443
  }
380
444
  }
381
- if (anyInvalid) {
382
- process.exitCode = 1;
383
- }
445
+ }
446
+ if (anyInvalid) {
447
+ process.exitCode = 1;
384
448
  }
385
449
  return;
386
450
  }
@@ -423,9 +487,11 @@ export function registerSchemaCommand(program) {
423
487
  for (const issue of result.issues) {
424
488
  console.log(` ${issue.level}: ${issue.message}`);
425
489
  }
426
- process.exitCode = 1;
427
490
  }
428
491
  }
492
+ if (!result.valid) {
493
+ process.exitCode = 1;
494
+ }
429
495
  }
430
496
  catch (error) {
431
497
  if (options?.json) {
@@ -487,6 +553,9 @@ export function registerSchemaCommand(program) {
487
553
  // Determine source location
488
554
  const sourceResolution = getSchemaResolution(source, projectRoot);
489
555
  const sourceLocation = sourceResolution?.source || 'package';
556
+ // Validate the complete source before a forced fork removes anything.
557
+ const trustedSourceDir = fs.realpathSync(sourceDir);
558
+ assertSchemaTreeCanBeCopied(trustedSourceDir);
490
559
  // Check destination
491
560
  const destinationDir = path.join(getProjectSchemasDir(projectRoot), destinationName);
492
561
  if (fs.existsSync(destinationDir)) {
@@ -513,7 +582,7 @@ export function registerSchemaCommand(program) {
513
582
  // Copy schema
514
583
  if (spinner)
515
584
  spinner.start(`Forking '${source}' to '${destinationName}'...`);
516
- copyDirRecursive(sourceDir, destinationDir);
585
+ copyDirRecursive(trustedSourceDir, destinationDir);
517
586
  // Update name in schema.yaml
518
587
  const destSchemaPath = path.join(destinationDir, 'schema.yaml');
519
588
  const schemaContent = fs.readFileSync(destSchemaPath, 'utf-8');
@@ -1,12 +1,31 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
- import { join } from 'path';
2
+ import path, { join } from 'path';
3
3
  import { MarkdownParser } from '../core/parsers/markdown-parser.js';
4
4
  import { Validator } from '../core/validation/validator.js';
5
5
  import { isInteractive } from '../utils/interactive.js';
6
6
  import { getSpecIds } from '../utils/item-discovery.js';
7
7
  import { discoverSpecFiles } from '../utils/spec-discovery.js';
8
+ import { FileSystemUtils } from '../utils/file-system.js';
8
9
  const SPECS_DIR = 'openspec/specs';
9
- function parseSpecFromFile(specPath, specId) {
10
+ function assertSpecPath(specsDir, specPath) {
11
+ const relativePath = path.relative(path.resolve(specsDir), path.resolve(specPath));
12
+ if (relativePath === '..' ||
13
+ relativePath.startsWith(`..${path.sep}`) ||
14
+ path.isAbsolute(relativePath)) {
15
+ throw new Error(`Path is outside the allowed directory: ${specPath}`);
16
+ }
17
+ try {
18
+ // Preserve confined spec.md links, including links to a sibling capability.
19
+ FileSystemUtils.assertPathWithin(specsDir, specPath);
20
+ }
21
+ catch {
22
+ // A capability directory may intentionally be a monorepo symlink. Treat it
23
+ // as the trust root while still rejecting a link outside that capability.
24
+ FileSystemUtils.assertPathWithin(path.dirname(specPath), specPath);
25
+ }
26
+ }
27
+ function parseSpecFromFile(specsDir, specPath, specId) {
28
+ assertSpecPath(specsDir, specPath);
10
29
  const content = readFileSync(specPath, 'utf-8');
11
30
  const parser = new MarkdownParser(content);
12
31
  return parser.parseSpec(specId);
@@ -41,7 +60,8 @@ function filterSpec(spec, options) {
41
60
  * Print the raw markdown content for a spec file without any formatting.
42
61
  * Raw-first behavior ensures text mode is a passthrough for deterministic output.
43
62
  */
44
- function printSpecTextRaw(specPath) {
63
+ function printSpecTextRaw(specsDir, specPath) {
64
+ assertSpecPath(specsDir, specPath);
45
65
  const content = readFileSync(specPath, 'utf-8');
46
66
  console.log(content);
47
67
  }
@@ -70,6 +90,7 @@ export class SpecCommand {
70
90
  }
71
91
  }
72
92
  const specPath = join(this.specsDir, specId, 'spec.md');
93
+ assertSpecPath(this.specsDir, specPath);
73
94
  if (!existsSync(specPath)) {
74
95
  // Root-aware callers get the absolute path; the cwd-based noun form
75
96
  // keeps its historical forward-slash relative message on all platforms.
@@ -80,7 +101,7 @@ export class SpecCommand {
80
101
  if (options.requirements && options.requirement) {
81
102
  throw new Error('Options --requirements and --requirement cannot be used together');
82
103
  }
83
- const parsed = parseSpecFromFile(specPath, specId);
104
+ const parsed = parseSpecFromFile(this.specsDir, specPath, specId);
84
105
  const filtered = filterSpec(parsed, options);
85
106
  const output = {
86
107
  id: specId,
@@ -94,7 +115,7 @@ export class SpecCommand {
94
115
  console.log(JSON.stringify(output, null, 2));
95
116
  return;
96
117
  }
97
- printSpecTextRaw(specPath);
118
+ printSpecTextRaw(this.specsDir, specPath);
98
119
  }
99
120
  }
100
121
  export function registerSpecCommand(rootProgram) {
@@ -138,7 +159,8 @@ export function registerSpecCommand(rootProgram) {
138
159
  const specs = discovered
139
160
  .map(({ id, specFile }) => {
140
161
  try {
141
- const spec = parseSpecFromFile(specFile, id);
162
+ assertSpecPath(SPECS_DIR, specFile);
163
+ const spec = parseSpecFromFile(SPECS_DIR, specFile, id);
142
164
  return {
143
165
  id,
144
166
  title: spec.name,
@@ -199,10 +221,12 @@ export function registerSpecCommand(rootProgram) {
199
221
  }
200
222
  }
201
223
  const specPath = join(SPECS_DIR, specId, 'spec.md');
224
+ assertSpecPath(SPECS_DIR, specPath);
202
225
  if (!existsSync(specPath)) {
203
226
  throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`);
204
227
  }
205
228
  const validator = new Validator(options.strict);
229
+ assertSpecPath(SPECS_DIR, specPath);
206
230
  const report = await validator.validateSpec(specPath);
207
231
  if (options.json) {
208
232
  console.log(JSON.stringify(report, null, 2));
@@ -145,7 +145,7 @@ export class ValidateCommand {
145
145
  if (type === 'change') {
146
146
  const changeDir = path.join(root.changesDir, id);
147
147
  const start = Date.now();
148
- const report = await validator.validateChangeDeltaSpecs(changeDir);
148
+ const report = await validator.validateChangeDeltaSpecs(changeDir, { mainSpecsDir: root.specsDir });
149
149
  const durationMs = Date.now() - start;
150
150
  this.printReport('change', id, report, durationMs, opts.json, root);
151
151
  // Non-zero exit if invalid (keeps enriched output test semantics)
@@ -222,7 +222,7 @@ export class ValidateCommand {
222
222
  queue.push(async () => {
223
223
  const start = Date.now();
224
224
  const changeDir = path.join(root.changesDir, id);
225
- const report = await validator.validateChangeDeltaSpecs(changeDir);
225
+ const report = await validator.validateChangeDeltaSpecs(changeDir, { mainSpecsDir: root.specsDir });
226
226
  const durationMs = Date.now() - start;
227
227
  return { id, type: 'change', valid: report.valid, issues: report.issues, durationMs };
228
228
  });
@@ -7,13 +7,14 @@
7
7
  import ora from 'ora';
8
8
  import path from 'path';
9
9
  import * as fs from 'fs';
10
- import { loadChangeContext, generateInstructions, resolveSchema, resolveArtifactOutputs, } from '../../core/artifact-graph/index.js';
10
+ import { loadChangeContext, generateInstructions, resolveSchema, resolveArtifactOutputPath, resolveArtifactOutputs, } from '../../core/artifact-graph/index.js';
11
11
  import { getChangeDir, resolveCurrentPlanningHomeSync, } from '../../core/planning-home.js';
12
12
  import { resolveRootForCommand, withStoreFlag, toPlanningHome, toRootOutput, } from '../../core/root-selection.js';
13
13
  import { assembleReferenceIndex, renderReferencedStoresBlock, renderReferencedStoresSection, } from '../../core/references.js';
14
14
  import { readRegistrySnapshot } from '../../core/store/registry.js';
15
15
  import { loadOperationInputs, readProjectConfig, } from '../../core/project-config.js';
16
16
  import { validateChangeExists, validateSchemaExists, } from './shared.js';
17
+ import { parseTaskLines } from '../../utils/task-progress.js';
17
18
  // -----------------------------------------------------------------------------
18
19
  // Artifact Instructions Command
19
20
  // -----------------------------------------------------------------------------
@@ -206,25 +207,26 @@ export function printInstructionsText(instructions, isBlocked) {
206
207
  // Apply Instructions Command
207
208
  // -----------------------------------------------------------------------------
208
209
  /**
209
- * Parses tasks.md content and extracts task items with their completion status.
210
+ * Turns parsed task lines into the listed task items.
211
+ *
212
+ * A checkbox with no text after it is left out of the list: this is work for an
213
+ * agent to act on and tick off, and a bare `- [ ]` gives it nothing to match.
214
+ * It still counts toward progress, which is taken from every parsed line, so
215
+ * this list can be shorter than the totals beside it but never disagrees with
216
+ * `openspec list` or archive about how much work is left. An empty list is also
217
+ * what puts apply in its "nothing to work on" state, so a file of nothing but
218
+ * text-less checkboxes asks to be rewritten instead of being called done.
210
219
  */
211
- function parseTasksFile(content) {
220
+ function toTaskItems(parsed) {
212
221
  const tasks = [];
213
- const lines = content.split('\n');
214
- let taskIndex = 0;
215
- for (const line of lines) {
216
- // Match checkbox patterns: - [ ] or - [x] or - [X]
217
- const checkboxMatch = line.match(/^[-*]\s*\[([ xX])\]\s*(.+)\s*$/);
218
- if (checkboxMatch) {
219
- taskIndex++;
220
- const done = checkboxMatch[1].toLowerCase() === 'x';
221
- const description = checkboxMatch[2].trim();
222
- tasks.push({
223
- id: `${taskIndex}`,
224
- description,
225
- done,
226
- });
227
- }
222
+ for (const task of parsed) {
223
+ if (task.description.length === 0)
224
+ continue;
225
+ tasks.push({
226
+ id: `${tasks.length + 1}`,
227
+ description: task.description,
228
+ done: task.done,
229
+ });
228
230
  }
229
231
  return tasks;
230
232
  }
@@ -274,19 +276,21 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
274
276
  }
275
277
  }
276
278
  // Parse tasks if tracking file exists
277
- let tasks = [];
279
+ let parsedTasks = [];
278
280
  let tracksFileExists = false;
279
281
  if (tracksFile) {
280
- const tracksPath = path.join(changeDir, tracksFile);
282
+ const tracksPath = resolveArtifactOutputPath(changeDir, tracksFile);
281
283
  tracksFileExists = fs.existsSync(tracksPath);
282
284
  if (tracksFileExists) {
283
285
  const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8');
284
- tasks = parseTasksFile(tasksContent);
286
+ parsedTasks = parseTaskLines(tasksContent);
285
287
  }
286
288
  }
287
- // Calculate progress
288
- const total = tasks.length;
289
- const complete = tasks.filter((t) => t.done).length;
289
+ const tasks = toTaskItems(parsedTasks);
290
+ // Calculate progress over every checkbox in the file, listed or not, so these
291
+ // numbers match `openspec list` and archive's incomplete-task check.
292
+ const total = parsedTasks.length;
293
+ const complete = parsedTasks.filter((task) => task.done).length;
290
294
  const remaining = total - complete;
291
295
  // Determine state and instruction
292
296
  let state;
@@ -301,11 +305,12 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
301
305
  state = 'blocked';
302
306
  instruction = `The ${tracksFilename} file is missing and must be created.\nUse openspec-continue-change to generate the tracking file.`;
303
307
  }
304
- else if (tracksFile && tracksFileExists && total === 0) {
305
- // Tracking file exists but contains no tasks
308
+ else if (tracksFile && tracksFileExists && tasks.length === 0) {
309
+ // Tracking file exists but lists nothing an agent can work on: either no
310
+ // checkboxes at all, or only checkboxes with no text after them.
306
311
  const tracksFilename = path.basename(tracksFile);
307
312
  state = 'blocked';
308
- instruction = `The ${tracksFilename} file exists but contains no tasks.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`;
313
+ instruction = `The ${tracksFilename} file exists but contains no tasks to work on.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`;
309
314
  }
310
315
  else if (tracksFile && remaining === 0 && total > 0) {
311
316
  state = 'all_done';
@@ -89,9 +89,9 @@ export function printStatusText(status) {
89
89
  }
90
90
  console.log(line);
91
91
  }
92
- if (status.isComplete) {
92
+ if (status.isPlanningComplete) {
93
93
  console.log();
94
- console.log(chalk.green('All artifacts complete!'));
94
+ console.log(chalk.green('All planning artifacts complete!'));
95
95
  }
96
96
  }
97
97
  //# sourceMappingURL=status.js.map
@@ -39,11 +39,21 @@ export async function templatesCommand(options) {
39
39
  else {
40
40
  source = 'package';
41
41
  }
42
- const templates = graph.getAllArtifacts().map((artifact) => ({
43
- artifactId: artifact.id,
44
- templatePath: FileSystemUtils.canonicalizeExistingPath(path.join(schemaDir, 'templates', artifact.template)),
45
- source,
46
- }));
42
+ const templatesDir = path.join(schemaDir, 'templates');
43
+ const templates = graph.getAllArtifacts().map((artifact) => {
44
+ const templatePath = path.join(templatesDir, artifact.template);
45
+ try {
46
+ FileSystemUtils.assertPathWithin(templatesDir, templatePath);
47
+ return {
48
+ artifactId: artifact.id,
49
+ templatePath: FileSystemUtils.canonicalizeExistingPath(templatePath),
50
+ source,
51
+ };
52
+ }
53
+ catch {
54
+ throw new Error(`Template '${artifact.template}' for artifact '${artifact.id}' points outside the schema templates directory`);
55
+ }
56
+ });
47
57
  spinner?.stop();
48
58
  if (options.json) {
49
59
  const output = {};
@@ -1,3 +1,17 @@
1
+ /**
2
+ * True when the ONLY thing wrong with a rebuilt spec is that it has no
3
+ * requirements. That is the exact failure retiring a capability replaces
4
+ * (#1302); anything else means the spec is broken in a way the author still has
5
+ * to fix, so archive must abort exactly as it always did instead of retiring.
6
+ *
7
+ * Asking the validator - rather than counting requirement blocks a second time -
8
+ * is what makes "this spec could not have been written anyway" true by
9
+ * construction. The two counts genuinely disagree: `MarkdownParser` accepts any
10
+ * `###` heading under `## Requirements` as a requirement, while the delta block
11
+ * parser only indexes canonical `### Requirement:` headers and sweeps the rest
12
+ * into the preamble, which survives into the rebuilt spec.
13
+ */
14
+ export declare function isRetirableSpec(specName: string, rebuilt: string): Promise<boolean>;
1
15
  export interface ArchiveOptions {
2
16
  yes?: boolean;
3
17
  skipSpecs?: boolean;