@fission-ai/openspec 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/index.d.ts +15 -0
  3. package/dist/cli/index.js +39 -5
  4. package/dist/commands/change.js +16 -1
  5. package/dist/commands/config.d.ts +5 -0
  6. package/dist/commands/config.js +5 -1
  7. package/dist/commands/schema.js +281 -47
  8. package/dist/commands/spec.js +30 -6
  9. package/dist/commands/validate.d.ts +23 -0
  10. package/dist/commands/validate.js +148 -4
  11. package/dist/commands/workflow/instructions.js +32 -27
  12. package/dist/commands/workflow/schemas.d.ts +2 -0
  13. package/dist/commands/workflow/schemas.js +9 -2
  14. package/dist/commands/workflow/status.js +2 -2
  15. package/dist/commands/workflow/templates.js +15 -5
  16. package/dist/core/archive.d.ts +14 -0
  17. package/dist/core/archive.js +1272 -151
  18. package/dist/core/artifact-graph/index.d.ts +1 -1
  19. package/dist/core/artifact-graph/index.js +1 -1
  20. package/dist/core/artifact-graph/instruction-loader.d.ts +3 -1
  21. package/dist/core/artifact-graph/instruction-loader.js +15 -5
  22. package/dist/core/artifact-graph/outputs.d.ts +1 -0
  23. package/dist/core/artifact-graph/outputs.js +67 -5
  24. package/dist/core/artifact-graph/resolver.d.ts +0 -12
  25. package/dist/core/artifact-graph/resolver.js +49 -9
  26. package/dist/core/artifact-graph/types.js +21 -3
  27. package/dist/core/available-tools.d.ts +2 -2
  28. package/dist/core/available-tools.js +14 -3
  29. package/dist/core/change-metadata/schema.d.ts +1 -0
  30. package/dist/core/change-metadata/schema.js +7 -0
  31. package/dist/core/change-status-policy.js +2 -2
  32. package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
  33. package/dist/core/command-generation/adapters/command-code.js +34 -0
  34. package/dist/core/command-generation/adapters/index.d.ts +1 -0
  35. package/dist/core/command-generation/adapters/index.js +1 -0
  36. package/dist/core/command-generation/registry.js +2 -0
  37. package/dist/core/completions/command-registry.js +13 -0
  38. package/dist/core/config-schema.d.ts +3 -0
  39. package/dist/core/config-schema.js +28 -1
  40. package/dist/core/config.d.ts +4 -0
  41. package/dist/core/config.js +40 -17
  42. package/dist/core/file-state.js +56 -24
  43. package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
  44. package/dist/core/github-copilot/cloud-agent.js +497 -0
  45. package/dist/core/global-config.d.ts +11 -0
  46. package/dist/core/init.d.ts +23 -0
  47. package/dist/core/init.js +253 -46
  48. package/dist/core/legacy-cleanup.d.ts +10 -0
  49. package/dist/core/legacy-cleanup.js +52 -28
  50. package/dist/core/migration.d.ts +6 -4
  51. package/dist/core/migration.js +78 -22
  52. package/dist/core/parsers/requirement-blocks.d.ts +10 -0
  53. package/dist/core/parsers/requirement-blocks.js +93 -1
  54. package/dist/core/parsers/requirement-text.d.ts +8 -0
  55. package/dist/core/parsers/requirement-text.js +4 -3
  56. package/dist/core/parsers/spec-structure.d.ts +1 -1
  57. package/dist/core/parsers/spec-structure.js +17 -1
  58. package/dist/core/profile-sync-drift.js +40 -5
  59. package/dist/core/project-config.d.ts +3 -0
  60. package/dist/core/project-config.js +31 -1
  61. package/dist/core/root-selection.d.ts +1 -1
  62. package/dist/core/shared/index.d.ts +1 -0
  63. package/dist/core/shared/index.js +1 -0
  64. package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
  65. package/dist/core/shared/skill-content-equivalence.js +41 -0
  66. package/dist/core/shared/skill-paths.d.ts +13 -0
  67. package/dist/core/shared/skill-paths.js +23 -0
  68. package/dist/core/shared/tool-detection.js +66 -35
  69. package/dist/core/shared-skill-target.d.ts +31 -0
  70. package/dist/core/shared-skill-target.js +186 -0
  71. package/dist/core/specs-apply.d.ts +72 -0
  72. package/dist/core/specs-apply.js +459 -59
  73. package/dist/core/templates/skill-templates.d.ts +1 -1
  74. package/dist/core/templates/skill-templates.js +1 -1
  75. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  76. package/dist/core/templates/workflows/apply-change.js +25 -182
  77. package/dist/core/templates/workflows/archive-change.js +8 -4
  78. package/dist/core/templates/workflows/bulk-archive-change.js +30 -26
  79. package/dist/core/templates/workflows/continue-change.js +6 -6
  80. package/dist/core/templates/workflows/explore.js +42 -18
  81. package/dist/core/templates/workflows/onboard.js +9 -3
  82. package/dist/core/templates/workflows/propose.js +72 -20
  83. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  84. package/dist/core/templates/workflows/store-selection.js +1 -1
  85. package/dist/core/templates/workflows/sync-specs.js +96 -18
  86. package/dist/core/templates/workflows/update-change.js +10 -8
  87. package/dist/core/update.d.ts +1 -0
  88. package/dist/core/update.js +136 -30
  89. package/dist/core/validation/task-numbering.d.ts +16 -0
  90. package/dist/core/validation/task-numbering.js +57 -0
  91. package/dist/core/validation/validator.d.ts +26 -3
  92. package/dist/core/validation/validator.js +189 -19
  93. package/dist/core/version-check.js +6 -11
  94. package/dist/telemetry/config.d.ts +3 -4
  95. package/dist/telemetry/index.d.ts +12 -5
  96. package/dist/telemetry/index.js +27 -9
  97. package/dist/utils/change-metadata.d.ts +17 -4
  98. package/dist/utils/change-metadata.js +26 -4
  99. package/dist/utils/ci.d.ts +12 -0
  100. package/dist/utils/ci.js +16 -0
  101. package/dist/utils/command-references.d.ts +14 -2
  102. package/dist/utils/command-references.js +45 -3
  103. package/dist/utils/file-system.d.ts +10 -0
  104. package/dist/utils/file-system.js +70 -0
  105. package/dist/utils/interactive.d.ts +52 -0
  106. package/dist/utils/interactive.js +135 -0
  107. package/dist/utils/spec-discovery.d.ts +4 -4
  108. package/dist/utils/spec-discovery.js +19 -6
  109. package/dist/utils/task-progress.d.ts +51 -1
  110. package/dist/utils/task-progress.js +93 -41
  111. package/package.json +1 -1
  112. package/schemas/spec-driven/schema.yaml +8 -6
  113. package/schemas/spec-driven/templates/proposal.md +6 -4
@@ -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 SHALL/MUST and at least one scenario
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 specFiles = (await discoverSpecFiles(specsDir)).map(spec => spec.specFile);
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 in a capability folder (e.g. specs/<capability>/spec.md) — a file at the specs/ root is ignored when the change is applied or archived.',
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 specFiles) {
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({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage(`ADDED "${block.name}"`, block.name) });
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({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage(`MODIFIED "${block.name}"`, block.name) });
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 enforcement for main specs (#1156). The main-spec
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: a body that omits the keyword errors, with the
424
- // targeted "move it to the body line" hint when the keyword is in the header
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 || !this.containsShallOrMust(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 an error message for a requirement block whose body lacks SHALL/MUST.
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 interface TelemetryConfig {
4
- anonymousId?: string;
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
- * Disabled when:
5
- * - OPENSPEC_TELEMETRY=0
6
- * - DO_NOT_TRACK=1
7
- * - CI=true (any CI environment)
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(): Promise<void>;
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.
@@ -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 or DO_NOT_TRACK=1
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
- * Disabled when:
59
- * - OPENSPEC_TELEMETRY=0
60
- * - DO_NOT_TRACK=1
61
- * - CI=true (any CI environment)
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 (process.env.CI === 'true') {
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 SkipSpecsMarker {
58
+ export interface MetadataMarker {
59
59
  /**
60
60
  * True when the metadata parses under ChangeMetadataSchema, sets
61
- * skip_specs: true, and names a schema that loads.
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: skip_specs appears in a file that
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): SkipSpecsMarker;
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
- return /^\s*(['"]?)skip_specs\1\s*:/m.test(raw)
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.skip_specs !== true) {
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
- 'skip_specs' in parsed &&
227
- parsed.skip_specs !== false;
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
@@ -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`). Falls back to the default `/openspec-*` form.
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;