@fission-ai/openspec 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/index.js +6 -1
  3. package/dist/commands/change.js +15 -1
  4. package/dist/commands/schema.js +89 -20
  5. package/dist/commands/spec.js +30 -6
  6. package/dist/commands/validate.js +2 -2
  7. package/dist/commands/workflow/instructions.js +32 -27
  8. package/dist/commands/workflow/status.js +2 -2
  9. package/dist/commands/workflow/templates.js +15 -5
  10. package/dist/core/archive.d.ts +14 -0
  11. package/dist/core/archive.js +1265 -151
  12. package/dist/core/artifact-graph/index.d.ts +1 -1
  13. package/dist/core/artifact-graph/index.js +1 -1
  14. package/dist/core/artifact-graph/instruction-loader.d.ts +3 -1
  15. package/dist/core/artifact-graph/instruction-loader.js +15 -5
  16. package/dist/core/artifact-graph/outputs.d.ts +1 -0
  17. package/dist/core/artifact-graph/outputs.js +67 -5
  18. package/dist/core/artifact-graph/resolver.js +35 -9
  19. package/dist/core/artifact-graph/types.js +21 -3
  20. package/dist/core/available-tools.d.ts +2 -2
  21. package/dist/core/available-tools.js +14 -3
  22. package/dist/core/change-metadata/schema.d.ts +1 -0
  23. package/dist/core/change-metadata/schema.js +7 -0
  24. package/dist/core/change-status-policy.js +2 -2
  25. package/dist/core/completions/command-registry.js +8 -0
  26. package/dist/core/config-schema.d.ts +3 -0
  27. package/dist/core/config-schema.js +28 -1
  28. package/dist/core/config.d.ts +3 -0
  29. package/dist/core/config.js +24 -2
  30. package/dist/core/file-state.js +56 -24
  31. package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
  32. package/dist/core/github-copilot/cloud-agent.js +497 -0
  33. package/dist/core/global-config.d.ts +11 -0
  34. package/dist/core/init.d.ts +23 -0
  35. package/dist/core/init.js +234 -39
  36. package/dist/core/legacy-cleanup.js +4 -2
  37. package/dist/core/migration.d.ts +6 -4
  38. package/dist/core/migration.js +78 -22
  39. package/dist/core/parsers/requirement-blocks.d.ts +10 -0
  40. package/dist/core/parsers/requirement-blocks.js +60 -0
  41. package/dist/core/parsers/spec-structure.d.ts +1 -1
  42. package/dist/core/parsers/spec-structure.js +17 -1
  43. package/dist/core/profile-sync-drift.js +40 -5
  44. package/dist/core/project-config.d.ts +3 -0
  45. package/dist/core/project-config.js +31 -1
  46. package/dist/core/shared/index.d.ts +1 -0
  47. package/dist/core/shared/index.js +1 -0
  48. package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
  49. package/dist/core/shared/skill-content-equivalence.js +41 -0
  50. package/dist/core/shared/skill-paths.d.ts +13 -0
  51. package/dist/core/shared/skill-paths.js +23 -0
  52. package/dist/core/shared/tool-detection.js +66 -35
  53. package/dist/core/shared-skill-target.d.ts +16 -0
  54. package/dist/core/shared-skill-target.js +156 -0
  55. package/dist/core/specs-apply.d.ts +72 -0
  56. package/dist/core/specs-apply.js +448 -55
  57. package/dist/core/templates/skill-templates.d.ts +1 -1
  58. package/dist/core/templates/skill-templates.js +1 -1
  59. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  60. package/dist/core/templates/workflows/apply-change.js +22 -182
  61. package/dist/core/templates/workflows/archive-change.js +8 -4
  62. package/dist/core/templates/workflows/bulk-archive-change.js +30 -26
  63. package/dist/core/templates/workflows/continue-change.js +6 -6
  64. package/dist/core/templates/workflows/explore.js +42 -18
  65. package/dist/core/templates/workflows/onboard.js +9 -3
  66. package/dist/core/templates/workflows/propose.js +72 -20
  67. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  68. package/dist/core/templates/workflows/store-selection.js +1 -1
  69. package/dist/core/templates/workflows/sync-specs.js +96 -18
  70. package/dist/core/templates/workflows/update-change.js +10 -8
  71. package/dist/core/update.d.ts +1 -0
  72. package/dist/core/update.js +103 -24
  73. package/dist/core/validation/validator.d.ts +23 -3
  74. package/dist/core/validation/validator.js +134 -18
  75. package/dist/core/version-check.js +6 -11
  76. package/dist/telemetry/config.d.ts +3 -4
  77. package/dist/telemetry/index.d.ts +9 -4
  78. package/dist/telemetry/index.js +20 -8
  79. package/dist/utils/change-metadata.d.ts +17 -4
  80. package/dist/utils/change-metadata.js +26 -4
  81. package/dist/utils/ci.d.ts +12 -0
  82. package/dist/utils/ci.js +16 -0
  83. package/dist/utils/command-references.d.ts +14 -2
  84. package/dist/utils/command-references.js +45 -3
  85. package/dist/utils/file-system.d.ts +10 -0
  86. package/dist/utils/file-system.js +70 -0
  87. package/dist/utils/interactive.d.ts +23 -0
  88. package/dist/utils/interactive.js +32 -0
  89. package/dist/utils/spec-discovery.d.ts +4 -4
  90. package/dist/utils/spec-discovery.js +19 -6
  91. package/dist/utils/task-progress.d.ts +17 -0
  92. package/dist/utils/task-progress.js +41 -13
  93. package/package.json +1 -1
  94. package/schemas/spec-driven/schema.yaml +8 -6
  95. package/schemas/spec-driven/templates/proposal.md +6 -4
@@ -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 SHALL/MUST and at least one scenario
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
- validateChangeDeltaSpecs(changeDir: string): Promise<ValidationReport>;
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 an error message for a requirement block whose body lacks SHALL/MUST.
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 SHALL/MUST and at least one scenario
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 specFiles = (await discoverSpecFiles(specsDir)).map(spec => spec.specFile);
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 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.',
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 specFiles) {
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({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage(`ADDED "${block.name}"`, block.name) });
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({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage(`MODIFIED "${block.name}"`, block.name) });
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 enforcement for main specs (#1156). The main-spec
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: 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
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 || !this.containsShallOrMust(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 an error message for a requirement block whose body lacks SHALL/MUST.
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 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
  /**
@@ -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;
@@ -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 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;