@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
|
@@ -12,12 +12,32 @@ export declare class Validator {
|
|
|
12
12
|
* Validate delta-formatted spec files under a change directory.
|
|
13
13
|
* Enforces:
|
|
14
14
|
* - At least one delta across all files
|
|
15
|
-
* - ADDED/MODIFIED: each requirement has
|
|
15
|
+
* - ADDED/MODIFIED: each requirement has at least one scenario; missing
|
|
16
|
+
* English SHALL/MUST keywords are guidance unless strict mode is enabled
|
|
16
17
|
* - REMOVED: names only; no scenario/description required
|
|
17
18
|
* - RENAMED: pairs well-formed
|
|
18
19
|
* - No duplicates within sections; no cross-section conflicts per spec
|
|
20
|
+
*
|
|
21
|
+
* When `options.mainSpecsDir` is given, MODIFIED blocks are also checked
|
|
22
|
+
* against the current main specs for the scenario loss archive refuses to
|
|
23
|
+
* apply (#1477). Omitting it keeps the change-only checks, so callers with
|
|
24
|
+
* no main specs root (and existing library callers) behave as before.
|
|
25
|
+
*/
|
|
26
|
+
validateChangeDeltaSpecs(changeDir: string, options?: {
|
|
27
|
+
mainSpecsDir?: string;
|
|
28
|
+
}): Promise<ValidationReport>;
|
|
29
|
+
/**
|
|
30
|
+
* Report MODIFIED requirements whose block omits a scenario the main spec
|
|
31
|
+
* still carries. Uses the same comparison archive applies, so validate can
|
|
32
|
+
* only report what archive would refuse.
|
|
33
|
+
*
|
|
34
|
+
* Silent when the main spec or the requirement header is absent: applying a
|
|
35
|
+
* MODIFIED against a base that is not there yet is a different failure (a
|
|
36
|
+
* sister change still in flight is the legitimate case), and archive is the
|
|
37
|
+
* gate for it. A spec that exists but cannot be read is not absent, though —
|
|
38
|
+
* archive aborts on it, so reporting it beats calling the change valid.
|
|
19
39
|
*/
|
|
20
|
-
|
|
40
|
+
private findScenarioLossIssues;
|
|
21
41
|
private formatInvalidMarkerMessage;
|
|
22
42
|
private convertZodErrors;
|
|
23
43
|
private applySpecRules;
|
|
@@ -29,7 +49,7 @@ export declare class Validator {
|
|
|
29
49
|
private extractRequirementText;
|
|
30
50
|
private containsShallOrMust;
|
|
31
51
|
/**
|
|
32
|
-
* Build
|
|
52
|
+
* Build a message for a requirement block whose body lacks SHALL/MUST.
|
|
33
53
|
*
|
|
34
54
|
* When the SHALL/MUST keyword already appears in the requirement header (e.g.
|
|
35
55
|
* `### Requirement: The system SHALL ...`) the original generic error
|
|
@@ -4,7 +4,7 @@ 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';
|
|
@@ -101,12 +101,18 @@ export class Validator {
|
|
|
101
101
|
* Validate delta-formatted spec files under a change directory.
|
|
102
102
|
* Enforces:
|
|
103
103
|
* - At least one delta across all files
|
|
104
|
-
* - ADDED/MODIFIED: each requirement has
|
|
104
|
+
* - ADDED/MODIFIED: each requirement has at least one scenario; missing
|
|
105
|
+
* English SHALL/MUST keywords are guidance unless strict mode is enabled
|
|
105
106
|
* - REMOVED: names only; no scenario/description required
|
|
106
107
|
* - RENAMED: pairs well-formed
|
|
107
108
|
* - No duplicates within sections; no cross-section conflicts per spec
|
|
109
|
+
*
|
|
110
|
+
* When `options.mainSpecsDir` is given, MODIFIED blocks are also checked
|
|
111
|
+
* against the current main specs for the scenario loss archive refuses to
|
|
112
|
+
* apply (#1477). Omitting it keeps the change-only checks, so callers with
|
|
113
|
+
* no main specs root (and existing library callers) behave as before.
|
|
108
114
|
*/
|
|
109
|
-
async validateChangeDeltaSpecs(changeDir) {
|
|
115
|
+
async validateChangeDeltaSpecs(changeDir, options = {}) {
|
|
110
116
|
const issues = [];
|
|
111
117
|
const specsDir = path.join(changeDir, 'specs');
|
|
112
118
|
let totalDeltas = 0;
|
|
@@ -119,7 +125,7 @@ export class Validator {
|
|
|
119
125
|
// path silently skips (#1385). It finds spec.md at any depth, covering
|
|
120
126
|
// both specs/<capability>/spec.md and the nested multi-area
|
|
121
127
|
// specs/<area>/<capability>/spec.md layout (#1182b).
|
|
122
|
-
const
|
|
128
|
+
const discoveredSpecs = await discoverSpecFiles(specsDir);
|
|
123
129
|
// A spec.md directly at the specs/ root has no capability folder, so the
|
|
124
130
|
// merge path drops it: without this error the change validates clean and
|
|
125
131
|
// archives while its requirements never reach openspec/specs/ (#1385).
|
|
@@ -131,10 +137,10 @@ export class Validator {
|
|
|
131
137
|
issues.push({
|
|
132
138
|
level: 'ERROR',
|
|
133
139
|
path: 'spec.md',
|
|
134
|
-
message: 'Delta spec found at specs/spec.md. Delta specs must live
|
|
140
|
+
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
141
|
});
|
|
136
142
|
}
|
|
137
|
-
for (const specFile of
|
|
143
|
+
for (const { id: specId, specFile } of discoveredSpecs) {
|
|
138
144
|
let content;
|
|
139
145
|
try {
|
|
140
146
|
content = await fs.readFile(specFile, 'utf-8');
|
|
@@ -204,7 +210,11 @@ export class Validator {
|
|
|
204
210
|
});
|
|
205
211
|
}
|
|
206
212
|
else if (!this.containsShallOrMust(requirementText)) {
|
|
207
|
-
issues.push({
|
|
213
|
+
issues.push({
|
|
214
|
+
level: 'WARNING',
|
|
215
|
+
path: entryPath,
|
|
216
|
+
message: this.buildMissingShallOrMustMessage(`ADDED "${block.name}"`, block.name, true),
|
|
217
|
+
});
|
|
208
218
|
}
|
|
209
219
|
const scenarioCount = this.countScenarios(block.raw);
|
|
210
220
|
if (scenarioCount < 1) {
|
|
@@ -232,13 +242,24 @@ export class Validator {
|
|
|
232
242
|
});
|
|
233
243
|
}
|
|
234
244
|
else if (!this.containsShallOrMust(requirementText)) {
|
|
235
|
-
issues.push({
|
|
245
|
+
issues.push({
|
|
246
|
+
level: 'WARNING',
|
|
247
|
+
path: entryPath,
|
|
248
|
+
message: this.buildMissingShallOrMustMessage(`MODIFIED "${block.name}"`, block.name, true),
|
|
249
|
+
});
|
|
236
250
|
}
|
|
237
251
|
const scenarioCount = this.countScenarios(block.raw);
|
|
238
252
|
if (scenarioCount < 1) {
|
|
239
253
|
issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must include at least one scenario` });
|
|
240
254
|
}
|
|
241
255
|
}
|
|
256
|
+
// Run archive's scenario-loss check here too, so the change fails at
|
|
257
|
+
// authoring time instead of days later at archive time (#1477).
|
|
258
|
+
if (options.mainSpecsDir && plan.modified.length > 0) {
|
|
259
|
+
const mainSpecFile = path.join(options.mainSpecsDir, ...specId.split('/'), 'spec.md');
|
|
260
|
+
FileSystemUtils.assertPathWithin(path.dirname(mainSpecFile), mainSpecFile);
|
|
261
|
+
issues.push(...(await this.findScenarioLossIssues(plan.modified, plan.renamed, mainSpecFile, entryPath, path.dirname(mainSpecFile))));
|
|
262
|
+
}
|
|
242
263
|
// Validate REMOVED (names only)
|
|
243
264
|
for (const name of plan.removed) {
|
|
244
265
|
const key = normalizeRequirementName(name);
|
|
@@ -368,6 +389,94 @@ export class Validator {
|
|
|
368
389
|
}
|
|
369
390
|
return this.createReport(issues);
|
|
370
391
|
}
|
|
392
|
+
/**
|
|
393
|
+
* Report MODIFIED requirements whose block omits a scenario the main spec
|
|
394
|
+
* still carries. Uses the same comparison archive applies, so validate can
|
|
395
|
+
* only report what archive would refuse.
|
|
396
|
+
*
|
|
397
|
+
* Silent when the main spec or the requirement header is absent: applying a
|
|
398
|
+
* MODIFIED against a base that is not there yet is a different failure (a
|
|
399
|
+
* sister change still in flight is the legitimate case), and archive is the
|
|
400
|
+
* gate for it. A spec that exists but cannot be read is not absent, though —
|
|
401
|
+
* archive aborts on it, so reporting it beats calling the change valid.
|
|
402
|
+
*/
|
|
403
|
+
async findScenarioLossIssues(modified, renamed, mainSpecFile, entryPath, mainSpecRoot) {
|
|
404
|
+
let mainContent;
|
|
405
|
+
FileSystemUtils.assertPathWithin(mainSpecRoot, mainSpecFile);
|
|
406
|
+
try {
|
|
407
|
+
mainContent = await fs.readFile(mainSpecFile, 'utf-8');
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
const code = error?.code;
|
|
411
|
+
// Reported only for the codes that mean the file itself is unusable, and
|
|
412
|
+
// will be just as unusable when archive reads it. Everything else -
|
|
413
|
+
// ENOENT/ENOTDIR ("no main spec"), and transient resource errors like
|
|
414
|
+
// EMFILE that say nothing about the file - stays silent rather than
|
|
415
|
+
// failing a change that is fine. `validate --all` reads six changes at
|
|
416
|
+
// once, so a resource error must never become a verdict.
|
|
417
|
+
const UNUSABLE = new Set(['EACCES', 'EPERM', 'EISDIR', 'ELOOP', 'ENAMETOOLONG']);
|
|
418
|
+
if (!code || !UNUSABLE.has(code))
|
|
419
|
+
return [];
|
|
420
|
+
return [
|
|
421
|
+
{
|
|
422
|
+
level: 'ERROR',
|
|
423
|
+
path: entryPath,
|
|
424
|
+
message: `Could not read ${FileSystemUtils.toPosixPath(mainSpecFile)} to check the MODIFIED requirements against it ` +
|
|
425
|
+
`(${code}). Archive reads the same file, so fix the file before archiving.`,
|
|
426
|
+
},
|
|
427
|
+
];
|
|
428
|
+
}
|
|
429
|
+
const currentBlocks = new Map();
|
|
430
|
+
for (const block of extractRequirementsSection(mainContent).bodyBlocks) {
|
|
431
|
+
currentBlocks.set(normalizeRequirementName(block.name), block);
|
|
432
|
+
}
|
|
433
|
+
// Archive applies RENAMED before MODIFIED, so a MODIFIED naming the new
|
|
434
|
+
// header is compared against the renamed block's scenarios. Fall back to
|
|
435
|
+
// the old header, or a rename-plus-modify pair would skip the check.
|
|
436
|
+
const renamedFrom = new Map(renamed.map(({ from, to }) => [normalizeRequirementName(to), normalizeRequirementName(from)]));
|
|
437
|
+
// Walked, not looked up once: renames chain (A→B then B→C leaves C holding
|
|
438
|
+
// A's block), and the visited set stops a cycle from looping forever. Every
|
|
439
|
+
// name in a rename cycle is also a rename FROM, so the skip above already
|
|
440
|
+
// keeps the walk out of one; the guard stays because the cost of being
|
|
441
|
+
// wrong about that is a hung CLI, not a wrong message.
|
|
442
|
+
const currentBlockFor = (name) => {
|
|
443
|
+
const visited = new Set();
|
|
444
|
+
let key = name;
|
|
445
|
+
while (key !== undefined && !visited.has(key)) {
|
|
446
|
+
const block = currentBlocks.get(key);
|
|
447
|
+
if (block)
|
|
448
|
+
return block;
|
|
449
|
+
visited.add(key);
|
|
450
|
+
key = renamedFrom.get(key);
|
|
451
|
+
}
|
|
452
|
+
return undefined;
|
|
453
|
+
};
|
|
454
|
+
// A MODIFIED naming a header the same delta renames away is already
|
|
455
|
+
// reported ("MODIFIED references old name from RENAMED"), and the block it
|
|
456
|
+
// would land on is not the one it names — so any scenario named here would
|
|
457
|
+
// send the author after the wrong requirement.
|
|
458
|
+
const renamedAway = new Set(renamed.map(({ from }) => normalizeRequirementName(from)));
|
|
459
|
+
const issues = [];
|
|
460
|
+
for (const block of modified) {
|
|
461
|
+
const key = normalizeRequirementName(block.name);
|
|
462
|
+
if (renamedAway.has(key))
|
|
463
|
+
continue;
|
|
464
|
+
const current = currentBlockFor(key);
|
|
465
|
+
if (!current)
|
|
466
|
+
continue;
|
|
467
|
+
const missing = findMissingCurrentScenarios(current, block);
|
|
468
|
+
if (missing.length === 0)
|
|
469
|
+
continue;
|
|
470
|
+
issues.push({
|
|
471
|
+
level: 'ERROR',
|
|
472
|
+
path: entryPath,
|
|
473
|
+
message: `MODIFIED "${block.name}" omits scenario(s) the current spec still has: ` +
|
|
474
|
+
`${missing.map(name => `"${name}"`).join(', ')}. ` +
|
|
475
|
+
'Copy them into the MODIFIED block (a MODIFIED requirement replaces the whole block, so archive refuses to drop them).',
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return issues;
|
|
479
|
+
}
|
|
371
480
|
formatInvalidMarkerMessage(invalidReason) {
|
|
372
481
|
return `${VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA} (${invalidReason})`;
|
|
373
482
|
}
|
|
@@ -417,22 +526,28 @@ export class Validator {
|
|
|
417
526
|
});
|
|
418
527
|
}
|
|
419
528
|
});
|
|
420
|
-
// SHALL/MUST body-keyword
|
|
529
|
+
// SHALL/MUST body-keyword guidance for main specs (#1156, #243). The main-spec
|
|
421
530
|
// parser collapses the requirement header into `text`, so we recover the
|
|
422
531
|
// 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
|
|
532
|
+
// the delta detection. A non-empty body that omits the English keyword gets
|
|
533
|
+
// guidance, while a missing body remains an error. Emitted exactly once per
|
|
426
534
|
// requirement (the Zod refine that used to emit a generic error is removed).
|
|
427
535
|
extractRequirementsSection(content).bodyBlocks.forEach((block, index) => {
|
|
428
536
|
const requirementText = this.extractRequirementText(block.raw);
|
|
429
|
-
if (!requirementText
|
|
537
|
+
if (!requirementText) {
|
|
430
538
|
issues.push({
|
|
431
539
|
level: 'ERROR',
|
|
432
540
|
path: `requirements[${index}]`,
|
|
433
541
|
message: this.buildMissingShallOrMustMessage(`Requirement "${block.name}"`, block.name),
|
|
434
542
|
});
|
|
435
543
|
}
|
|
544
|
+
else if (!this.containsShallOrMust(requirementText)) {
|
|
545
|
+
issues.push({
|
|
546
|
+
level: 'WARNING',
|
|
547
|
+
path: `requirements[${index}]`,
|
|
548
|
+
message: this.buildMissingShallOrMustMessage(`Requirement "${block.name}"`, block.name, true),
|
|
549
|
+
});
|
|
550
|
+
}
|
|
436
551
|
});
|
|
437
552
|
return issues;
|
|
438
553
|
}
|
|
@@ -520,7 +635,7 @@ export class Validator {
|
|
|
520
635
|
return containsShallOrMustShared(text);
|
|
521
636
|
}
|
|
522
637
|
/**
|
|
523
|
-
* Build
|
|
638
|
+
* Build a message for a requirement block whose body lacks SHALL/MUST.
|
|
524
639
|
*
|
|
525
640
|
* When the SHALL/MUST keyword already appears in the requirement header (e.g.
|
|
526
641
|
* `### Requirement: The system SHALL ...`) the original generic error
|
|
@@ -529,12 +644,13 @@ export class Validator {
|
|
|
529
644
|
* on the requirement body line (the line right after the header), so we point
|
|
530
645
|
* the author at that exact fix when the keyword is found in the header only.
|
|
531
646
|
*/
|
|
532
|
-
buildMissingShallOrMustMessage(prefix, blockName) {
|
|
533
|
-
const base = `${prefix} must contain SHALL or MUST`;
|
|
647
|
+
buildMissingShallOrMustMessage(prefix, blockName, guidanceOnly = false) {
|
|
648
|
+
const base = `${prefix} ${guidanceOnly ? 'should' : 'must'} contain SHALL or MUST`;
|
|
649
|
+
const suffix = guidanceOnly ? ' (RFC 2119 best practice for English specs)' : '';
|
|
534
650
|
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
|
|
651
|
+
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
652
|
}
|
|
537
|
-
return base
|
|
653
|
+
return `${base}${suffix}`;
|
|
538
654
|
}
|
|
539
655
|
countScenarios(blockRaw) {
|
|
540
656
|
// 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
|
/**
|
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;
|
|
@@ -157,7 +169,7 @@ export async function maybeShowTelemetryNotice() {
|
|
|
157
169
|
return;
|
|
158
170
|
}
|
|
159
171
|
// Display notice
|
|
160
|
-
console.log('Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0');
|
|
172
|
+
console.log('Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0 or openspec config set telemetry.enabled false');
|
|
161
173
|
// Mark as seen
|
|
162
174
|
await updateTelemetryConfig({ noticeSeen: true });
|
|
163
175
|
}
|
|
@@ -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;
|