@fission-ai/openspec 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/cli/index.d.ts +15 -0
- package/dist/cli/index.js +39 -5
- package/dist/commands/change.js +16 -1
- package/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.js +5 -1
- package/dist/commands/schema.js +281 -47
- package/dist/commands/spec.js +30 -6
- package/dist/commands/validate.d.ts +23 -0
- package/dist/commands/validate.js +148 -4
- package/dist/commands/workflow/instructions.js +32 -27
- package/dist/commands/workflow/schemas.d.ts +2 -0
- package/dist/commands/workflow/schemas.js +9 -2
- package/dist/commands/workflow/status.js +2 -2
- package/dist/commands/workflow/templates.js +15 -5
- package/dist/core/archive.d.ts +14 -0
- package/dist/core/archive.js +1272 -151
- package/dist/core/artifact-graph/index.d.ts +1 -1
- package/dist/core/artifact-graph/index.js +1 -1
- package/dist/core/artifact-graph/instruction-loader.d.ts +3 -1
- package/dist/core/artifact-graph/instruction-loader.js +15 -5
- package/dist/core/artifact-graph/outputs.d.ts +1 -0
- package/dist/core/artifact-graph/outputs.js +67 -5
- package/dist/core/artifact-graph/resolver.d.ts +0 -12
- package/dist/core/artifact-graph/resolver.js +49 -9
- package/dist/core/artifact-graph/types.js +21 -3
- package/dist/core/available-tools.d.ts +2 -2
- package/dist/core/available-tools.js +14 -3
- package/dist/core/change-metadata/schema.d.ts +1 -0
- package/dist/core/change-metadata/schema.js +7 -0
- package/dist/core/change-status-policy.js +2 -2
- package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
- package/dist/core/command-generation/adapters/command-code.js +34 -0
- package/dist/core/command-generation/adapters/index.d.ts +1 -0
- package/dist/core/command-generation/adapters/index.js +1 -0
- package/dist/core/command-generation/registry.js +2 -0
- package/dist/core/completions/command-registry.js +13 -0
- package/dist/core/config-schema.d.ts +3 -0
- package/dist/core/config-schema.js +28 -1
- package/dist/core/config.d.ts +4 -0
- package/dist/core/config.js +40 -17
- package/dist/core/file-state.js +56 -24
- package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
- package/dist/core/github-copilot/cloud-agent.js +497 -0
- package/dist/core/global-config.d.ts +11 -0
- package/dist/core/init.d.ts +23 -0
- package/dist/core/init.js +253 -46
- package/dist/core/legacy-cleanup.d.ts +10 -0
- package/dist/core/legacy-cleanup.js +52 -28
- package/dist/core/migration.d.ts +6 -4
- package/dist/core/migration.js +78 -22
- package/dist/core/parsers/requirement-blocks.d.ts +10 -0
- package/dist/core/parsers/requirement-blocks.js +93 -1
- package/dist/core/parsers/requirement-text.d.ts +8 -0
- package/dist/core/parsers/requirement-text.js +4 -3
- package/dist/core/parsers/spec-structure.d.ts +1 -1
- package/dist/core/parsers/spec-structure.js +17 -1
- package/dist/core/profile-sync-drift.js +40 -5
- package/dist/core/project-config.d.ts +3 -0
- package/dist/core/project-config.js +31 -1
- package/dist/core/root-selection.d.ts +1 -1
- package/dist/core/shared/index.d.ts +1 -0
- package/dist/core/shared/index.js +1 -0
- package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
- package/dist/core/shared/skill-content-equivalence.js +41 -0
- package/dist/core/shared/skill-paths.d.ts +13 -0
- package/dist/core/shared/skill-paths.js +23 -0
- package/dist/core/shared/tool-detection.js +66 -35
- package/dist/core/shared-skill-target.d.ts +31 -0
- package/dist/core/shared-skill-target.js +186 -0
- package/dist/core/specs-apply.d.ts +72 -0
- package/dist/core/specs-apply.js +459 -59
- package/dist/core/templates/skill-templates.d.ts +1 -1
- package/dist/core/templates/skill-templates.js +1 -1
- package/dist/core/templates/workflows/apply-change.d.ts +10 -0
- package/dist/core/templates/workflows/apply-change.js +25 -182
- package/dist/core/templates/workflows/archive-change.js +8 -4
- package/dist/core/templates/workflows/bulk-archive-change.js +30 -26
- package/dist/core/templates/workflows/continue-change.js +6 -6
- package/dist/core/templates/workflows/explore.js +42 -18
- package/dist/core/templates/workflows/onboard.js +9 -3
- package/dist/core/templates/workflows/propose.js +72 -20
- package/dist/core/templates/workflows/store-selection.d.ts +1 -1
- package/dist/core/templates/workflows/store-selection.js +1 -1
- package/dist/core/templates/workflows/sync-specs.js +96 -18
- package/dist/core/templates/workflows/update-change.js +10 -8
- package/dist/core/update.d.ts +1 -0
- package/dist/core/update.js +136 -30
- package/dist/core/validation/task-numbering.d.ts +16 -0
- package/dist/core/validation/task-numbering.js +57 -0
- package/dist/core/validation/validator.d.ts +26 -3
- package/dist/core/validation/validator.js +189 -19
- package/dist/core/version-check.js +6 -11
- package/dist/telemetry/config.d.ts +3 -4
- package/dist/telemetry/index.d.ts +12 -5
- package/dist/telemetry/index.js +27 -9
- package/dist/utils/change-metadata.d.ts +17 -4
- package/dist/utils/change-metadata.js +26 -4
- package/dist/utils/ci.d.ts +12 -0
- package/dist/utils/ci.js +16 -0
- package/dist/utils/command-references.d.ts +14 -2
- package/dist/utils/command-references.js +45 -3
- package/dist/utils/file-system.d.ts +10 -0
- package/dist/utils/file-system.js +70 -0
- package/dist/utils/interactive.d.ts +52 -0
- package/dist/utils/interactive.js +135 -0
- package/dist/utils/spec-discovery.d.ts +4 -4
- package/dist/utils/spec-discovery.js +19 -6
- package/dist/utils/task-progress.d.ts +51 -1
- package/dist/utils/task-progress.js +93 -41
- package/package.json +1 -1
- package/schemas/spec-driven/schema.yaml +8 -6
- package/schemas/spec-driven/templates/proposal.md +6 -4
|
@@ -4,12 +4,15 @@ import { SpecSchema, ChangeSchema } from '../schemas/index.js';
|
|
|
4
4
|
import { MarkdownParser } from '../parsers/markdown-parser.js';
|
|
5
5
|
import { ChangeParser } from '../parsers/change-parser.js';
|
|
6
6
|
import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
|
|
7
|
-
import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection } from '../parsers/requirement-blocks.js';
|
|
7
|
+
import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, findMissingCurrentScenarios, } from '../parsers/requirement-blocks.js';
|
|
8
8
|
import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, } from '../parsers/requirement-text.js';
|
|
9
9
|
import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
|
|
10
10
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
11
11
|
import { discoverSpecFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
|
|
12
|
-
import { METADATA_FILENAME, readSkipSpecsMarker } from '../../utils/change-metadata.js';
|
|
12
|
+
import { METADATA_FILENAME, readSkipSpecsMarker, resolveSchemaForChange, } from '../../utils/change-metadata.js';
|
|
13
|
+
import { resolveTaskFilesForChange } from '../../utils/task-progress.js';
|
|
14
|
+
import { findTaskNumberingIssues } from './task-numbering.js';
|
|
15
|
+
import { getPackageSchemasDir, getSchemaDir } from '../artifact-graph/index.js';
|
|
13
16
|
export class Validator {
|
|
14
17
|
strictMode;
|
|
15
18
|
constructor(strictMode = false) {
|
|
@@ -101,12 +104,19 @@ export class Validator {
|
|
|
101
104
|
* Validate delta-formatted spec files under a change directory.
|
|
102
105
|
* Enforces:
|
|
103
106
|
* - At least one delta across all files
|
|
104
|
-
* - ADDED/MODIFIED: each requirement has
|
|
107
|
+
* - ADDED/MODIFIED: each requirement has at least one scenario; missing
|
|
108
|
+
* English SHALL/MUST keywords are guidance unless strict mode is enabled
|
|
105
109
|
* - REMOVED: names only; no scenario/description required
|
|
106
110
|
* - RENAMED: pairs well-formed
|
|
107
111
|
* - No duplicates within sections; no cross-section conflicts per spec
|
|
112
|
+
*
|
|
113
|
+
* When `options.mainSpecsDir` is given, MODIFIED blocks are also checked
|
|
114
|
+
* against the current main specs for the scenario loss archive refuses to
|
|
115
|
+
* apply (#1477). When `options.projectRoot` is given, the schema's tracked
|
|
116
|
+
* task files are checked for ambiguous numbering (#1520). Omitting either
|
|
117
|
+
* option keeps existing library and archive callers behaving as before.
|
|
108
118
|
*/
|
|
109
|
-
async validateChangeDeltaSpecs(changeDir) {
|
|
119
|
+
async validateChangeDeltaSpecs(changeDir, options = {}) {
|
|
110
120
|
const issues = [];
|
|
111
121
|
const specsDir = path.join(changeDir, 'specs');
|
|
112
122
|
let totalDeltas = 0;
|
|
@@ -119,7 +129,7 @@ export class Validator {
|
|
|
119
129
|
// path silently skips (#1385). It finds spec.md at any depth, covering
|
|
120
130
|
// both specs/<capability>/spec.md and the nested multi-area
|
|
121
131
|
// specs/<area>/<capability>/spec.md layout (#1182b).
|
|
122
|
-
const
|
|
132
|
+
const discoveredSpecs = await discoverSpecFiles(specsDir);
|
|
123
133
|
// A spec.md directly at the specs/ root has no capability folder, so the
|
|
124
134
|
// merge path drops it: without this error the change validates clean and
|
|
125
135
|
// archives while its requirements never reach openspec/specs/ (#1385).
|
|
@@ -131,10 +141,10 @@ export class Validator {
|
|
|
131
141
|
issues.push({
|
|
132
142
|
level: 'ERROR',
|
|
133
143
|
path: 'spec.md',
|
|
134
|
-
message: 'Delta spec found at specs/spec.md. Delta specs must live
|
|
144
|
+
message: 'Delta spec found at specs/spec.md. Delta specs must live under a capability path (e.g. specs/<capability-path>/spec.md) — a file at the specs/ root is ignored when the change is applied or archived.',
|
|
135
145
|
});
|
|
136
146
|
}
|
|
137
|
-
for (const specFile of
|
|
147
|
+
for (const { id: specId, specFile } of discoveredSpecs) {
|
|
138
148
|
let content;
|
|
139
149
|
try {
|
|
140
150
|
content = await fs.readFile(specFile, 'utf-8');
|
|
@@ -204,7 +214,11 @@ export class Validator {
|
|
|
204
214
|
});
|
|
205
215
|
}
|
|
206
216
|
else if (!this.containsShallOrMust(requirementText)) {
|
|
207
|
-
issues.push({
|
|
217
|
+
issues.push({
|
|
218
|
+
level: 'WARNING',
|
|
219
|
+
path: entryPath,
|
|
220
|
+
message: this.buildMissingShallOrMustMessage(`ADDED "${block.name}"`, block.name, true),
|
|
221
|
+
});
|
|
208
222
|
}
|
|
209
223
|
const scenarioCount = this.countScenarios(block.raw);
|
|
210
224
|
if (scenarioCount < 1) {
|
|
@@ -232,13 +246,24 @@ export class Validator {
|
|
|
232
246
|
});
|
|
233
247
|
}
|
|
234
248
|
else if (!this.containsShallOrMust(requirementText)) {
|
|
235
|
-
issues.push({
|
|
249
|
+
issues.push({
|
|
250
|
+
level: 'WARNING',
|
|
251
|
+
path: entryPath,
|
|
252
|
+
message: this.buildMissingShallOrMustMessage(`MODIFIED "${block.name}"`, block.name, true),
|
|
253
|
+
});
|
|
236
254
|
}
|
|
237
255
|
const scenarioCount = this.countScenarios(block.raw);
|
|
238
256
|
if (scenarioCount < 1) {
|
|
239
257
|
issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must include at least one scenario` });
|
|
240
258
|
}
|
|
241
259
|
}
|
|
260
|
+
// Run archive's scenario-loss check here too, so the change fails at
|
|
261
|
+
// authoring time instead of days later at archive time (#1477).
|
|
262
|
+
if (options.mainSpecsDir && plan.modified.length > 0) {
|
|
263
|
+
const mainSpecFile = path.join(options.mainSpecsDir, ...specId.split('/'), 'spec.md');
|
|
264
|
+
FileSystemUtils.assertPathWithin(path.dirname(mainSpecFile), mainSpecFile);
|
|
265
|
+
issues.push(...(await this.findScenarioLossIssues(plan.modified, plan.renamed, mainSpecFile, entryPath, path.dirname(mainSpecFile))));
|
|
266
|
+
}
|
|
242
267
|
// Validate REMOVED (names only)
|
|
243
268
|
for (const name of plan.removed) {
|
|
244
269
|
const key = normalizeRequirementName(name);
|
|
@@ -366,8 +391,146 @@ export class Validator {
|
|
|
366
391
|
issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) });
|
|
367
392
|
}
|
|
368
393
|
}
|
|
394
|
+
if (options.projectRoot) {
|
|
395
|
+
issues.push(...await this.collectTaskNumberingIssues(changeDir, options.projectRoot));
|
|
396
|
+
}
|
|
369
397
|
return this.createReport(issues);
|
|
370
398
|
}
|
|
399
|
+
async collectTaskNumberingIssues(changeDir, projectRoot) {
|
|
400
|
+
try {
|
|
401
|
+
const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot).replace(/\.ya?ml$/, '');
|
|
402
|
+
const schemaDir = getSchemaDir(schemaName, projectRoot);
|
|
403
|
+
const builtInSchemaDir = path.join(getPackageSchemasDir(), 'spec-driven');
|
|
404
|
+
if (schemaName !== 'spec-driven' ||
|
|
405
|
+
schemaDir === null ||
|
|
406
|
+
FileSystemUtils.canonicalizeExistingPath(schemaDir) !==
|
|
407
|
+
FileSystemUtils.canonicalizeExistingPath(builtInSchemaDir)) {
|
|
408
|
+
return [];
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return [];
|
|
413
|
+
}
|
|
414
|
+
let taskFiles;
|
|
415
|
+
try {
|
|
416
|
+
taskFiles = resolveTaskFilesForChange(changeDir, projectRoot);
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return [];
|
|
420
|
+
}
|
|
421
|
+
if (taskFiles.length === 0) {
|
|
422
|
+
taskFiles = [path.join(changeDir, 'tasks.md')];
|
|
423
|
+
}
|
|
424
|
+
const documents = [];
|
|
425
|
+
for (const taskFile of taskFiles) {
|
|
426
|
+
let content;
|
|
427
|
+
try {
|
|
428
|
+
content = await fs.readFile(taskFile, 'utf-8');
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
documents.push({
|
|
434
|
+
path: FileSystemUtils.toPosixPath(path.relative(changeDir, taskFile)),
|
|
435
|
+
content,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
documents.sort((left, right) => left.path.localeCompare(right.path));
|
|
439
|
+
return findTaskNumberingIssues(documents).map((issue) => ({
|
|
440
|
+
level: 'WARNING',
|
|
441
|
+
path: issue.path,
|
|
442
|
+
line: issue.line,
|
|
443
|
+
message: issue.message,
|
|
444
|
+
}));
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Report MODIFIED requirements whose block omits a scenario the main spec
|
|
448
|
+
* still carries. Uses the same comparison archive applies, so validate can
|
|
449
|
+
* only report what archive would refuse.
|
|
450
|
+
*
|
|
451
|
+
* Silent when the main spec or the requirement header is absent: applying a
|
|
452
|
+
* MODIFIED against a base that is not there yet is a different failure (a
|
|
453
|
+
* sister change still in flight is the legitimate case), and archive is the
|
|
454
|
+
* gate for it. A spec that exists but cannot be read is not absent, though —
|
|
455
|
+
* archive aborts on it, so reporting it beats calling the change valid.
|
|
456
|
+
*/
|
|
457
|
+
async findScenarioLossIssues(modified, renamed, mainSpecFile, entryPath, mainSpecRoot) {
|
|
458
|
+
let mainContent;
|
|
459
|
+
FileSystemUtils.assertPathWithin(mainSpecRoot, mainSpecFile);
|
|
460
|
+
try {
|
|
461
|
+
mainContent = await fs.readFile(mainSpecFile, 'utf-8');
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
const code = error?.code;
|
|
465
|
+
// Reported only for the codes that mean the file itself is unusable, and
|
|
466
|
+
// will be just as unusable when archive reads it. Everything else -
|
|
467
|
+
// ENOENT/ENOTDIR ("no main spec"), and transient resource errors like
|
|
468
|
+
// EMFILE that say nothing about the file - stays silent rather than
|
|
469
|
+
// failing a change that is fine. `validate --all` reads six changes at
|
|
470
|
+
// once, so a resource error must never become a verdict.
|
|
471
|
+
const UNUSABLE = new Set(['EACCES', 'EPERM', 'EISDIR', 'ELOOP', 'ENAMETOOLONG']);
|
|
472
|
+
if (!code || !UNUSABLE.has(code))
|
|
473
|
+
return [];
|
|
474
|
+
return [
|
|
475
|
+
{
|
|
476
|
+
level: 'ERROR',
|
|
477
|
+
path: entryPath,
|
|
478
|
+
message: `Could not read ${FileSystemUtils.toPosixPath(mainSpecFile)} to check the MODIFIED requirements against it ` +
|
|
479
|
+
`(${code}). Archive reads the same file, so fix the file before archiving.`,
|
|
480
|
+
},
|
|
481
|
+
];
|
|
482
|
+
}
|
|
483
|
+
const currentBlocks = new Map();
|
|
484
|
+
for (const block of extractRequirementsSection(mainContent).bodyBlocks) {
|
|
485
|
+
currentBlocks.set(normalizeRequirementName(block.name), block);
|
|
486
|
+
}
|
|
487
|
+
// Archive applies RENAMED before MODIFIED, so a MODIFIED naming the new
|
|
488
|
+
// header is compared against the renamed block's scenarios. Fall back to
|
|
489
|
+
// the old header, or a rename-plus-modify pair would skip the check.
|
|
490
|
+
const renamedFrom = new Map(renamed.map(({ from, to }) => [normalizeRequirementName(to), normalizeRequirementName(from)]));
|
|
491
|
+
// Walked, not looked up once: renames chain (A→B then B→C leaves C holding
|
|
492
|
+
// A's block), and the visited set stops a cycle from looping forever. Every
|
|
493
|
+
// name in a rename cycle is also a rename FROM, so the skip above already
|
|
494
|
+
// keeps the walk out of one; the guard stays because the cost of being
|
|
495
|
+
// wrong about that is a hung CLI, not a wrong message.
|
|
496
|
+
const currentBlockFor = (name) => {
|
|
497
|
+
const visited = new Set();
|
|
498
|
+
let key = name;
|
|
499
|
+
while (key !== undefined && !visited.has(key)) {
|
|
500
|
+
const block = currentBlocks.get(key);
|
|
501
|
+
if (block)
|
|
502
|
+
return block;
|
|
503
|
+
visited.add(key);
|
|
504
|
+
key = renamedFrom.get(key);
|
|
505
|
+
}
|
|
506
|
+
return undefined;
|
|
507
|
+
};
|
|
508
|
+
// A MODIFIED naming a header the same delta renames away is already
|
|
509
|
+
// reported ("MODIFIED references old name from RENAMED"), and the block it
|
|
510
|
+
// would land on is not the one it names — so any scenario named here would
|
|
511
|
+
// send the author after the wrong requirement.
|
|
512
|
+
const renamedAway = new Set(renamed.map(({ from }) => normalizeRequirementName(from)));
|
|
513
|
+
const issues = [];
|
|
514
|
+
for (const block of modified) {
|
|
515
|
+
const key = normalizeRequirementName(block.name);
|
|
516
|
+
if (renamedAway.has(key))
|
|
517
|
+
continue;
|
|
518
|
+
const current = currentBlockFor(key);
|
|
519
|
+
if (!current)
|
|
520
|
+
continue;
|
|
521
|
+
const missing = findMissingCurrentScenarios(current, block);
|
|
522
|
+
if (missing.length === 0)
|
|
523
|
+
continue;
|
|
524
|
+
issues.push({
|
|
525
|
+
level: 'ERROR',
|
|
526
|
+
path: entryPath,
|
|
527
|
+
message: `MODIFIED "${block.name}" omits scenario(s) the current spec still has: ` +
|
|
528
|
+
`${missing.map(name => `"${name}"`).join(', ')}. ` +
|
|
529
|
+
'Copy them into the MODIFIED block (a MODIFIED requirement replaces the whole block, so archive refuses to drop them).',
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
return issues;
|
|
533
|
+
}
|
|
371
534
|
formatInvalidMarkerMessage(invalidReason) {
|
|
372
535
|
return `${VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA} (${invalidReason})`;
|
|
373
536
|
}
|
|
@@ -417,22 +580,28 @@ export class Validator {
|
|
|
417
580
|
});
|
|
418
581
|
}
|
|
419
582
|
});
|
|
420
|
-
// SHALL/MUST body-keyword
|
|
583
|
+
// SHALL/MUST body-keyword guidance for main specs (#1156, #243). The main-spec
|
|
421
584
|
// parser collapses the requirement header into `text`, so we recover the
|
|
422
585
|
// header+body pairs here (the same source the delta path trusts) and reuse
|
|
423
|
-
// the delta detection
|
|
424
|
-
//
|
|
425
|
-
// only and the generic message otherwise. Emitted exactly once per
|
|
586
|
+
// the delta detection. A non-empty body that omits the English keyword gets
|
|
587
|
+
// guidance, while a missing body remains an error. Emitted exactly once per
|
|
426
588
|
// requirement (the Zod refine that used to emit a generic error is removed).
|
|
427
589
|
extractRequirementsSection(content).bodyBlocks.forEach((block, index) => {
|
|
428
590
|
const requirementText = this.extractRequirementText(block.raw);
|
|
429
|
-
if (!requirementText
|
|
591
|
+
if (!requirementText) {
|
|
430
592
|
issues.push({
|
|
431
593
|
level: 'ERROR',
|
|
432
594
|
path: `requirements[${index}]`,
|
|
433
595
|
message: this.buildMissingShallOrMustMessage(`Requirement "${block.name}"`, block.name),
|
|
434
596
|
});
|
|
435
597
|
}
|
|
598
|
+
else if (!this.containsShallOrMust(requirementText)) {
|
|
599
|
+
issues.push({
|
|
600
|
+
level: 'WARNING',
|
|
601
|
+
path: `requirements[${index}]`,
|
|
602
|
+
message: this.buildMissingShallOrMustMessage(`Requirement "${block.name}"`, block.name, true),
|
|
603
|
+
});
|
|
604
|
+
}
|
|
436
605
|
});
|
|
437
606
|
return issues;
|
|
438
607
|
}
|
|
@@ -520,7 +689,7 @@ export class Validator {
|
|
|
520
689
|
return containsShallOrMustShared(text);
|
|
521
690
|
}
|
|
522
691
|
/**
|
|
523
|
-
* Build
|
|
692
|
+
* Build a message for a requirement block whose body lacks SHALL/MUST.
|
|
524
693
|
*
|
|
525
694
|
* When the SHALL/MUST keyword already appears in the requirement header (e.g.
|
|
526
695
|
* `### Requirement: The system SHALL ...`) the original generic error
|
|
@@ -529,12 +698,13 @@ export class Validator {
|
|
|
529
698
|
* on the requirement body line (the line right after the header), so we point
|
|
530
699
|
* the author at that exact fix when the keyword is found in the header only.
|
|
531
700
|
*/
|
|
532
|
-
buildMissingShallOrMustMessage(prefix, blockName) {
|
|
533
|
-
const base = `${prefix} must contain SHALL or MUST`;
|
|
701
|
+
buildMissingShallOrMustMessage(prefix, blockName, guidanceOnly = false) {
|
|
702
|
+
const base = `${prefix} ${guidanceOnly ? 'should' : 'must'} contain SHALL or MUST`;
|
|
703
|
+
const suffix = guidanceOnly ? ' (RFC 2119 best practice for English specs)' : '';
|
|
534
704
|
if (this.containsShallOrMust(blockName)) {
|
|
535
|
-
return `${base} in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header
|
|
705
|
+
return `${base} in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.${suffix}`;
|
|
536
706
|
}
|
|
537
|
-
return base
|
|
707
|
+
return `${base}${suffix}`;
|
|
538
708
|
}
|
|
539
709
|
countScenarios(blockRaw) {
|
|
540
710
|
// Fence-aware count via the shared reader: a `#### Scenario:` inside a fenced
|
|
@@ -4,6 +4,8 @@ import https from 'https';
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { createRequire } from 'module';
|
|
6
6
|
import chalk from 'chalk';
|
|
7
|
+
import { isCiEnvironment } from '../utils/ci.js';
|
|
8
|
+
import { getGlobalConfig } from './global-config.js';
|
|
7
9
|
const require = createRequire(import.meta.url);
|
|
8
10
|
const { name: PACKAGE_NAME, version: OPENSPEC_VERSION } = require('../../package.json');
|
|
9
11
|
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
@@ -11,16 +13,6 @@ const REQUEST_TIMEOUT_MS = 1500;
|
|
|
11
13
|
const MAX_RESPONSE_BYTES = 256 * 1024;
|
|
12
14
|
const VERSION_PROBE_TIMEOUT_MS = 5000;
|
|
13
15
|
const MAX_REDIRECTS = 3;
|
|
14
|
-
/**
|
|
15
|
-
* `CI` set to anything meaningful means CI. Providers use "true", "1", "yes";
|
|
16
|
-
* only an explicit off-value counts as "not CI", so a value we do not know
|
|
17
|
-
* still suppresses the request rather than surprising a build.
|
|
18
|
-
*/
|
|
19
|
-
const CI_DISABLED_VALUES = new Set(['', 'false', '0', 'no', 'off']);
|
|
20
|
-
function isCiEnvironment() {
|
|
21
|
-
const value = process.env.CI;
|
|
22
|
-
return value !== undefined && !CI_DISABLED_VALUES.has(value.trim().toLowerCase());
|
|
23
|
-
}
|
|
24
16
|
/**
|
|
25
17
|
* A version we are willing to print. The registry only ever serves SemVer here,
|
|
26
18
|
* so anything else is either a broken mirror or a hostile response — and since
|
|
@@ -32,7 +24,7 @@ const SAFE_VERSION = /^\d{1,10}\.\d{1,10}\.\d{1,10}(?:-[0-9A-Za-z.-]{1,64})?(?:\
|
|
|
32
24
|
* The check is opt-out and must never get in the way: no network in CI or
|
|
33
25
|
* tests, an explicit escape hatch for anyone offline or air-gapped, and the
|
|
34
26
|
* same privacy signals telemetry already honors — a user who set DO_NOT_TRACK
|
|
35
|
-
* did not agree to a different outbound request.
|
|
27
|
+
* or telemetry.enabled false did not agree to a different outbound request.
|
|
36
28
|
*/
|
|
37
29
|
function isCheckEnabled() {
|
|
38
30
|
if (process.env.OPENSPEC_NO_UPDATE_CHECK !== undefined)
|
|
@@ -45,6 +37,9 @@ function isCheckEnabled() {
|
|
|
45
37
|
return false;
|
|
46
38
|
if (process.env.NODE_ENV === 'test')
|
|
47
39
|
return false;
|
|
40
|
+
// Same config opt-out as telemetry (env remains the hard override above).
|
|
41
|
+
if (getGlobalConfig().telemetry?.enabled === false)
|
|
42
|
+
return false;
|
|
48
43
|
return true;
|
|
49
44
|
}
|
|
50
45
|
/**
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
+
import { type TelemetryConfig } from '../core/global-config.js';
|
|
1
2
|
export declare const CONFIG_DIR_NAME = "openspec";
|
|
2
3
|
export declare const CONFIG_FILE_NAME = "config.json";
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
noticeSeen?: boolean;
|
|
6
|
-
}
|
|
4
|
+
/** Re-export shared telemetry section type (single source of truth in global-config). */
|
|
5
|
+
export type { TelemetryConfig };
|
|
7
6
|
export interface GlobalConfig {
|
|
8
7
|
telemetry?: TelemetryConfig;
|
|
9
8
|
[key: string]: unknown;
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Check if telemetry is enabled.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Precedence (first match wins):
|
|
5
|
+
* 1. OPENSPEC_TELEMETRY=0 → disabled
|
|
6
|
+
* 2. DO_NOT_TRACK=1 → disabled
|
|
7
|
+
* 3. CI set to a truthy/on value → disabled (same rule as version-check)
|
|
8
|
+
* 4. global config telemetry.enabled === false → disabled
|
|
9
|
+
* 5. otherwise enabled (unset config means on; opt-out model)
|
|
10
|
+
*
|
|
11
|
+
* Kept synchronous so call sites need not become async. Reads config via
|
|
12
|
+
* sync getGlobalConfig() rather than async getTelemetryConfig().
|
|
8
13
|
*/
|
|
9
14
|
export declare function isTelemetryEnabled(): boolean;
|
|
10
15
|
/**
|
|
@@ -22,7 +27,9 @@ export declare function trackCommand(commandName: string, version: string): Prom
|
|
|
22
27
|
/**
|
|
23
28
|
* Show first-run telemetry notice if not already seen.
|
|
24
29
|
*/
|
|
25
|
-
export declare function maybeShowTelemetryNotice(
|
|
30
|
+
export declare function maybeShowTelemetryNotice(options?: {
|
|
31
|
+
silent?: boolean;
|
|
32
|
+
}): Promise<void>;
|
|
26
33
|
/**
|
|
27
34
|
* Flush pending telemetry events.
|
|
28
35
|
* Call this before CLI exit.
|
package/dist/telemetry/index.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Privacy-first design:
|
|
5
5
|
* - Only tracks command name and version
|
|
6
6
|
* - No arguments, file paths, or content
|
|
7
|
-
* - Opt-out via OPENSPEC_TELEMETRY=0
|
|
7
|
+
* - Opt-out via OPENSPEC_TELEMETRY=0, DO_NOT_TRACK=1, or
|
|
8
|
+
* `openspec config set telemetry.enabled false`
|
|
8
9
|
* - Auto-disabled in CI environments
|
|
9
10
|
* - Anonymous ID is a random UUID with no relation to the user
|
|
10
11
|
*
|
|
@@ -19,6 +20,8 @@
|
|
|
19
20
|
* versions and broke installs (#1390).
|
|
20
21
|
*/
|
|
21
22
|
import { randomUUID } from 'crypto';
|
|
23
|
+
import { getGlobalConfig } from '../core/global-config.js';
|
|
24
|
+
import { isCiEnvironment } from '../utils/ci.js';
|
|
22
25
|
import { getTelemetryConfig, updateTelemetryConfig } from './config.js';
|
|
23
26
|
// PostHog API key - public key for client-side analytics
|
|
24
27
|
// This is safe to embed as it only allows sending events, not reading data
|
|
@@ -55,10 +58,15 @@ async function safeTelemetryFetch(url, options) {
|
|
|
55
58
|
/**
|
|
56
59
|
* Check if telemetry is enabled.
|
|
57
60
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
61
|
+
* Precedence (first match wins):
|
|
62
|
+
* 1. OPENSPEC_TELEMETRY=0 → disabled
|
|
63
|
+
* 2. DO_NOT_TRACK=1 → disabled
|
|
64
|
+
* 3. CI set to a truthy/on value → disabled (same rule as version-check)
|
|
65
|
+
* 4. global config telemetry.enabled === false → disabled
|
|
66
|
+
* 5. otherwise enabled (unset config means on; opt-out model)
|
|
67
|
+
*
|
|
68
|
+
* Kept synchronous so call sites need not become async. Reads config via
|
|
69
|
+
* sync getGlobalConfig() rather than async getTelemetryConfig().
|
|
62
70
|
*/
|
|
63
71
|
export function isTelemetryEnabled() {
|
|
64
72
|
// Check explicit opt-out
|
|
@@ -69,8 +77,12 @@ export function isTelemetryEnabled() {
|
|
|
69
77
|
if (process.env.DO_NOT_TRACK === '1') {
|
|
70
78
|
return false;
|
|
71
79
|
}
|
|
72
|
-
// Auto-disable in CI environments
|
|
73
|
-
if (
|
|
80
|
+
// Auto-disable in CI environments (providers use true/1/yes/…)
|
|
81
|
+
if (isCiEnvironment()) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
// Global config opt-out (env/CI remain hard overrides above)
|
|
85
|
+
if (getGlobalConfig().telemetry?.enabled === false) {
|
|
74
86
|
return false;
|
|
75
87
|
}
|
|
76
88
|
return true;
|
|
@@ -147,7 +159,7 @@ export async function trackCommand(commandName, version) {
|
|
|
147
159
|
/**
|
|
148
160
|
* Show first-run telemetry notice if not already seen.
|
|
149
161
|
*/
|
|
150
|
-
export async function maybeShowTelemetryNotice() {
|
|
162
|
+
export async function maybeShowTelemetryNotice(options = {}) {
|
|
151
163
|
if (!isTelemetryEnabled()) {
|
|
152
164
|
return;
|
|
153
165
|
}
|
|
@@ -156,8 +168,14 @@ export async function maybeShowTelemetryNotice() {
|
|
|
156
168
|
if (config.noticeSeen) {
|
|
157
169
|
return;
|
|
158
170
|
}
|
|
171
|
+
// In --json mode the notice would pollute stdout and break parsers, so
|
|
172
|
+
// defer it: skip the notice AND leave noticeSeen unset so the disclosure
|
|
173
|
+
// still appears on the user's first later non-JSON run.
|
|
174
|
+
if (options.silent) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
159
177
|
// Display notice
|
|
160
|
-
console.log('Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0');
|
|
178
|
+
console.log('Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0 or openspec config set telemetry.enabled false');
|
|
161
179
|
// Mark as seen
|
|
162
180
|
await updateTelemetryConfig({ noticeSeen: true });
|
|
163
181
|
}
|
|
@@ -55,19 +55,21 @@ export interface ResolveSchemaForChangeOptions {
|
|
|
55
55
|
* @returns The resolved schema name
|
|
56
56
|
*/
|
|
57
57
|
export declare function resolveSchemaForChange(changeDir: string, explicitSchema?: string, projectRootOverride?: string, options?: ResolveSchemaForChangeOptions): string;
|
|
58
|
-
export interface
|
|
58
|
+
export interface MetadataMarker {
|
|
59
59
|
/**
|
|
60
60
|
* True when the metadata parses under ChangeMetadataSchema, sets
|
|
61
|
-
*
|
|
61
|
+
* the requested boolean marker to true, and names a schema that loads.
|
|
62
62
|
*/
|
|
63
63
|
declared: boolean;
|
|
64
64
|
/**
|
|
65
|
-
* Set when the marker cannot be honored:
|
|
65
|
+
* Set when the marker cannot be honored: it appears in a file that
|
|
66
66
|
* fails the metadata contract, or the metadata file exists but cannot be
|
|
67
67
|
* read at all (so whether the marker is set cannot even be determined).
|
|
68
68
|
*/
|
|
69
69
|
invalidReason?: string;
|
|
70
70
|
}
|
|
71
|
+
/** @deprecated Use MetadataMarker. */
|
|
72
|
+
export type SkipSpecsMarker = MetadataMarker;
|
|
71
73
|
/**
|
|
72
74
|
* Non-throwing read of the skip_specs marker. The marker only counts when the
|
|
73
75
|
* metadata would load for status/instructions: the file parses under
|
|
@@ -81,5 +83,16 @@ export interface SkipSpecsMarker {
|
|
|
81
83
|
* Missing metadata means "not declared"; a marker that cannot be honored
|
|
82
84
|
* yields invalidReason so callers can say why.
|
|
83
85
|
*/
|
|
84
|
-
export declare function readSkipSpecsMarker(changeDir: string):
|
|
86
|
+
export declare function readSkipSpecsMarker(changeDir: string): MetadataMarker;
|
|
87
|
+
/**
|
|
88
|
+
* Non-throwing read of the retire_capabilities marker, with exactly the
|
|
89
|
+
* semantics `readSkipSpecsMarker` documents above.
|
|
90
|
+
*
|
|
91
|
+
* Gates the one archive action that removes a file from `openspec/specs/`: when
|
|
92
|
+
* a change's REMOVED entries take a capability's last requirement, archive
|
|
93
|
+
* deletes the emptied main spec rather than aborting on a spec it cannot write
|
|
94
|
+
* (#1302). Declared rather than inferred because the delete is recoverable only
|
|
95
|
+
* from git, so it is the author's call.
|
|
96
|
+
*/
|
|
97
|
+
export declare function readRetireCapabilitiesMarker(changeDir: string): MetadataMarker;
|
|
85
98
|
//# sourceMappingURL=change-metadata.d.ts.map
|
|
@@ -159,6 +159,27 @@ export function resolveSchemaForChange(changeDir, explicitSchema, projectRootOve
|
|
|
159
159
|
* yields invalidReason so callers can say why.
|
|
160
160
|
*/
|
|
161
161
|
export function readSkipSpecsMarker(changeDir) {
|
|
162
|
+
return readBooleanMarker(changeDir, 'skip_specs');
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Non-throwing read of the retire_capabilities marker, with exactly the
|
|
166
|
+
* semantics `readSkipSpecsMarker` documents above.
|
|
167
|
+
*
|
|
168
|
+
* Gates the one archive action that removes a file from `openspec/specs/`: when
|
|
169
|
+
* a change's REMOVED entries take a capability's last requirement, archive
|
|
170
|
+
* deletes the emptied main spec rather than aborting on a spec it cannot write
|
|
171
|
+
* (#1302). Declared rather than inferred because the delete is recoverable only
|
|
172
|
+
* from git, so it is the author's call.
|
|
173
|
+
*/
|
|
174
|
+
export function readRetireCapabilitiesMarker(changeDir) {
|
|
175
|
+
return readBooleanMarker(changeDir, 'retire_capabilities');
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Shared implementation for the boolean change-metadata markers, keyed by field
|
|
179
|
+
* name. One body rather than two, so a marker can never drift into honoring
|
|
180
|
+
* metadata the other rejects - the whole point of the contract described above.
|
|
181
|
+
*/
|
|
182
|
+
function readBooleanMarker(changeDir, key) {
|
|
162
183
|
let raw;
|
|
163
184
|
try {
|
|
164
185
|
raw = fs.readFileSync(path.join(changeDir, METADATA_FILENAME), 'utf-8');
|
|
@@ -184,13 +205,14 @@ export function readSkipSpecsMarker(changeDir) {
|
|
|
184
205
|
catch {
|
|
185
206
|
// Anchored so a comment like "# maybe add skip_specs later" does not
|
|
186
207
|
// claim the marker was set.
|
|
187
|
-
|
|
208
|
+
const mentioned = new RegExp(`^\\s*(['"]?)${key}\\1\\s*:`, 'm').test(raw);
|
|
209
|
+
return mentioned
|
|
188
210
|
? { declared: false, invalidReason: 'the file is not valid YAML' }
|
|
189
211
|
: { declared: false };
|
|
190
212
|
}
|
|
191
213
|
const result = ChangeMetadataSchema.safeParse(parsed);
|
|
192
214
|
if (result.success) {
|
|
193
|
-
if (result.data
|
|
215
|
+
if (result.data[key] !== true) {
|
|
194
216
|
return { declared: false };
|
|
195
217
|
}
|
|
196
218
|
// Schema loading is checked only when the marker is set: a broken schema
|
|
@@ -223,8 +245,8 @@ export function readSkipSpecsMarker(changeDir) {
|
|
|
223
245
|
// validate - the change simply is not marked.
|
|
224
246
|
const markerMentioned = typeof parsed === 'object' &&
|
|
225
247
|
parsed !== null &&
|
|
226
|
-
|
|
227
|
-
parsed
|
|
248
|
+
key in parsed &&
|
|
249
|
+
parsed[key] !== false;
|
|
228
250
|
if (markerMentioned) {
|
|
229
251
|
const first = result.error.issues[0];
|
|
230
252
|
const where = first.path.length > 0 ? `${first.path.join('.')}: ` : '';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CI environment detection shared by telemetry and the version check.
|
|
3
|
+
*
|
|
4
|
+
* Providers set CI to "true", "1", "yes", etc. Only an explicit off-value
|
|
5
|
+
* counts as "not CI", so an unknown value still suppresses outbound requests
|
|
6
|
+
* rather than surprising a build.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* True when `CI` is set to anything other than an explicit off-value.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isCiEnvironment(env?: NodeJS.ProcessEnv): boolean;
|
|
12
|
+
//# sourceMappingURL=ci.d.ts.map
|
package/dist/utils/ci.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CI environment detection shared by telemetry and the version check.
|
|
3
|
+
*
|
|
4
|
+
* Providers set CI to "true", "1", "yes", etc. Only an explicit off-value
|
|
5
|
+
* counts as "not CI", so an unknown value still suppresses outbound requests
|
|
6
|
+
* rather than surprising a build.
|
|
7
|
+
*/
|
|
8
|
+
const CI_DISABLED_VALUES = new Set(['', 'false', '0', 'no', 'off']);
|
|
9
|
+
/**
|
|
10
|
+
* True when `CI` is set to anything other than an explicit off-value.
|
|
11
|
+
*/
|
|
12
|
+
export function isCiEnvironment(env = process.env) {
|
|
13
|
+
const value = env.CI;
|
|
14
|
+
return value !== undefined && !CI_DISABLED_VALUES.has(value.trim().toLowerCase());
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=ci.js.map
|
|
@@ -25,6 +25,16 @@ import type { CommandInvocation } from '../core/command-generation/invocation.js
|
|
|
25
25
|
* transformCommandInvocations('/opsx:new', { style: 'flat', prefix: '@' }) // '@opsx-new'
|
|
26
26
|
*/
|
|
27
27
|
export declare function transformCommandInvocations(text: string, invocation: CommandInvocation): string;
|
|
28
|
+
/**
|
|
29
|
+
* Whether a tool references skills by natural language rather than a slash
|
|
30
|
+
* command (see NATURAL_LANGUAGE_SKILL_TOOLS).
|
|
31
|
+
*/
|
|
32
|
+
export declare function usesNaturalLanguageSkillReferences(toolId: string): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Keeps Codex's `$<name>` spelling first while making its canonical shared
|
|
35
|
+
* `.agents` tree usable by agents that invoke the same skills with `/<name>`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function transformToCodexCompatibleSkillReferences(text: string): string;
|
|
28
38
|
/**
|
|
29
39
|
* Transforms command references to skill references using the default `/`
|
|
30
40
|
* invocation prefix. Converts `/opsx:<command>` patterns to
|
|
@@ -46,9 +56,11 @@ export declare function transformToSkillReferences(text: string): string;
|
|
|
46
56
|
/**
|
|
47
57
|
* Returns the skill-reference transformer for a specific tool, honoring the
|
|
48
58
|
* tool's documented skill invocation syntax (e.g. Kimi Code's
|
|
49
|
-
* `/skill:openspec-propose`).
|
|
59
|
+
* `/skill:openspec-propose`). Tools with no slash surface (e.g. Rovo Dev) get
|
|
60
|
+
* natural-language references ("the openspec-propose skill"); everything else
|
|
61
|
+
* falls back to the default `/openspec-*` form.
|
|
50
62
|
*
|
|
51
|
-
* @param toolId - The AI tool identifier (e.g. 'kimi', 'vibe')
|
|
63
|
+
* @param toolId - The AI tool identifier (e.g. 'kimi', 'vibe', 'rovodev')
|
|
52
64
|
* @returns A transformer converting `/opsx:*` references to skill invocations
|
|
53
65
|
*/
|
|
54
66
|
export declare function getSkillReferenceTransformer(toolId: string): (text: string) => string;
|