@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.
- package/README.md +3 -1
- package/dist/cli/index.js +6 -1
- package/dist/commands/change.js +15 -1
- package/dist/commands/schema.js +89 -20
- package/dist/commands/spec.js +30 -6
- package/dist/commands/validate.js +2 -2
- package/dist/commands/workflow/instructions.js +32 -27
- 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 +1265 -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.js +35 -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/completions/command-registry.js +8 -0
- package/dist/core/config-schema.d.ts +3 -0
- package/dist/core/config-schema.js +28 -1
- package/dist/core/config.d.ts +3 -0
- package/dist/core/config.js +24 -2
- 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 +234 -39
- package/dist/core/legacy-cleanup.js +4 -2
- 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 +60 -0
- 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/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 +16 -0
- package/dist/core/shared-skill-target.js +156 -0
- package/dist/core/specs-apply.d.ts +72 -0
- package/dist/core/specs-apply.js +448 -55
- 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 +22 -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 +103 -24
- package/dist/core/validation/validator.d.ts +23 -3
- package/dist/core/validation/validator.js +134 -18
- package/dist/core/version-check.js +6 -11
- package/dist/telemetry/config.d.ts +3 -4
- package/dist/telemetry/index.d.ts +9 -4
- package/dist/telemetry/index.js +20 -8
- 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 +23 -0
- package/dist/utils/interactive.js +32 -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 +17 -0
- package/dist/utils/task-progress.js +41 -13
- package/package.json +1 -1
- package/schemas/spec-driven/schema.yaml +8 -6
- 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
|
|
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
|
|
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
|
}
|
package/dist/commands/change.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/schema.js
CHANGED
|
@@ -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
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
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
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
382
|
-
|
|
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(
|
|
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');
|
package/dist/commands/spec.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
220
|
+
function toTaskItems(parsed) {
|
|
212
221
|
const tasks = [];
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
279
|
+
let parsedTasks = [];
|
|
278
280
|
let tracksFileExists = false;
|
|
279
281
|
if (tracksFile) {
|
|
280
|
-
const tracksPath =
|
|
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
|
-
|
|
286
|
+
parsedTasks = parseTaskLines(tasksContent);
|
|
285
287
|
}
|
|
286
288
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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 &&
|
|
305
|
-
// Tracking file exists but
|
|
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.
|
|
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
|
|
43
|
-
|
|
44
|
-
templatePath
|
|
45
|
-
|
|
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 = {};
|
package/dist/core/archive.d.ts
CHANGED
|
@@ -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;
|