@fission-ai/openspec 1.14.3 → 1.16.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 (68) hide show
  1. package/README.md +12 -10
  2. package/dist/cli/index.js +47 -0
  3. package/dist/commands/completion.js +4 -2
  4. package/dist/commands/feedback.js +33 -20
  5. package/dist/commands/install-standalone.d.ts +27 -0
  6. package/dist/commands/install-standalone.js +150 -0
  7. package/dist/commands/schema.js +10 -3
  8. package/dist/commands/validate.js +33 -3
  9. package/dist/commands/workflow/instructions.js +15 -4
  10. package/dist/commands/workflow/new-change.js +12 -4
  11. package/dist/commands/workflow/status.js +5 -2
  12. package/dist/commands/workflow/templates.js +5 -2
  13. package/dist/core/artifact-graph/resolver.d.ts +6 -0
  14. package/dist/core/artifact-graph/resolver.js +11 -0
  15. package/dist/core/available-tools.d.ts +2 -1
  16. package/dist/core/available-tools.js +8 -28
  17. package/dist/core/completion-tip.d.ts +23 -0
  18. package/dist/core/completion-tip.js +4 -4
  19. package/dist/core/completions/command-registry.js +15 -0
  20. package/dist/core/completions/installers/zsh-installer.d.ts +9 -1
  21. package/dist/core/completions/installers/zsh-installer.js +64 -19
  22. package/dist/core/config-schema.d.ts +1 -0
  23. package/dist/core/config-schema.js +1 -0
  24. package/dist/core/file-view.d.ts +13 -0
  25. package/dist/core/file-view.js +14 -0
  26. package/dist/core/github-copilot/cloud-agent.d.ts +10 -2
  27. package/dist/core/github-copilot/cloud-agent.js +13 -9
  28. package/dist/core/global-config.d.ts +4 -0
  29. package/dist/core/global-config.js +5 -1
  30. package/dist/core/id.d.ts +8 -0
  31. package/dist/core/id.js +15 -0
  32. package/dist/core/legacy-cleanup.d.ts +11 -1
  33. package/dist/core/legacy-cleanup.js +15 -18
  34. package/dist/core/migration.d.ts +27 -1
  35. package/dist/core/migration.js +77 -29
  36. package/dist/core/parsers/code-fence.js +9 -1
  37. package/dist/core/parsers/requirement-blocks.d.ts +30 -0
  38. package/dist/core/parsers/requirement-blocks.js +22 -9
  39. package/dist/core/parsers/requirement-text.d.ts +14 -0
  40. package/dist/core/parsers/requirement-text.js +39 -0
  41. package/dist/core/parsers/spec-structure.js +1 -1
  42. package/dist/core/profile-sync-drift.d.ts +4 -3
  43. package/dist/core/profile-sync-drift.js +19 -16
  44. package/dist/core/project-config.d.ts +10 -0
  45. package/dist/core/project-config.js +26 -0
  46. package/dist/core/root-selection.d.ts +6 -0
  47. package/dist/core/root-selection.js +37 -0
  48. package/dist/core/shared/tool-detection.d.ts +12 -4
  49. package/dist/core/shared/tool-detection.js +29 -27
  50. package/dist/core/shared-skill-target.d.ts +12 -5
  51. package/dist/core/shared-skill-target.js +34 -23
  52. package/dist/core/standalone-tip.d.ts +6 -0
  53. package/dist/core/standalone-tip.js +38 -0
  54. package/dist/core/store/git.d.ts +22 -1
  55. package/dist/core/store/git.js +59 -4
  56. package/dist/core/templates/workflows/verify-change.js +4 -4
  57. package/dist/core/update-plan.d.ts +127 -0
  58. package/dist/core/update-plan.js +390 -0
  59. package/dist/core/update.d.ts +45 -1
  60. package/dist/core/update.js +279 -137
  61. package/dist/core/validation/validator.d.ts +27 -2
  62. package/dist/core/validation/validator.js +58 -4
  63. package/dist/core/version-check.d.ts +8 -1
  64. package/dist/core/version-check.js +48 -5
  65. package/dist/utils/executable-path.d.ts +5 -0
  66. package/dist/utils/executable-path.js +44 -0
  67. package/package.json +5 -4
  68. package/schemas/spec-driven/schema.yaml +1 -0
@@ -12,8 +12,10 @@ 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 at least one scenario; missing
16
- * English SHALL/MUST keywords are guidance unless strict mode is enabled
15
+ * - ADDED/MODIFIED: each requirement has at least one scenario; a scenario
16
+ * with no WHEN/GIVEN or THEN step is guidance (WARNING) unless strict mode
17
+ * is enabled; missing English SHALL/MUST keywords are guidance unless
18
+ * strict mode is enabled
17
19
  * - REMOVED: names only; no scenario/description required
18
20
  * - RENAMED: pairs well-formed
19
21
  * - No duplicates within sections; no cross-section conflicts per spec
@@ -90,6 +92,14 @@ export declare class Validator {
90
92
  private findScenarioLossIssues;
91
93
  private formatInvalidMarkerMessage;
92
94
  private convertZodErrors;
95
+ /**
96
+ * Every guidance and error the main-spec path emits about one spec file
97
+ * (#1156, #243): structure, a placeholder or too-brief Purpose, requirement
98
+ * length, a requirement with no scenario, a missing English SHALL/MUST in the
99
+ * body, and a counted scenario with no trigger or outcome. The step check is
100
+ * the same one the delta path runs, on the same `#### ` blocks, so a scenario
101
+ * that asserts nothing is reported wherever it lives.
102
+ */
93
103
  private applySpecRules;
94
104
  private applyChangeRules;
95
105
  private enrichTopLevelError;
@@ -124,6 +134,21 @@ export declare class Validator {
124
134
  * not count it. Empty when the block has no bare scenario header.
125
135
  */
126
136
  private emptyScenarioHint;
137
+ /**
138
+ * A counted scenario whose body carries neither a **WHEN** (or **GIVEN**)
139
+ * step nor a **THEN** step can never fail: nothing triggers it and nothing
140
+ * is asserted (#2078). Read from the same blocks the scenario-loss comparison
141
+ * walks, so the two checks cannot disagree about what a scenario is.
142
+ * Only `Scenario:`-labeled blocks are checked: any level-4 child counts as a
143
+ * scenario for loss purposes, but an unlabeled `#### Notes` child is prose
144
+ * on purpose, and a missing WHEN/THEN there is noise, not a finding.
145
+ *
146
+ * A WARNING, like the missing SHALL/MUST keyword: plain `validate` and
147
+ * `archive` keep agreeing, and `--strict` — which already fails on any
148
+ * warning — is the bar that enforces it.
149
+ */
150
+ private findScenarioStepIssues;
151
+ /** Render a list of section names as prose: `a`, `a and b`, `a, b and c`. */
127
152
  private formatSectionList;
128
153
  }
129
154
  //# sourceMappingURL=validator.d.ts.map
@@ -5,8 +5,8 @@ import { MarkdownParser } from '../parsers/markdown-parser.js';
5
5
  import { ChangeParser } from '../parsers/change-parser.js';
6
6
  import { findSpecUpdates, buildUpdatedSpec } from '../specs-apply.js';
7
7
  import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
8
- import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, diffScenarioNames, describeScenarioBalance, describeUnappliedContent, } from '../parsers/requirement-blocks.js';
9
- import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, countEmptyScenarios, } from '../parsers/requirement-text.js';
8
+ import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, diffScenarioNames, describeScenarioBalance, parseScenarioBlocks, isScenarioLabeledHeader, describeUnappliedContent, } from '../parsers/requirement-blocks.js';
9
+ import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, countEmptyScenarios, hasScenarioBody, scenarioStepCoverage, } from '../parsers/requirement-text.js';
10
10
  import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
11
11
  import { FileSystemUtils } from '../../utils/file-system.js';
12
12
  import { assertDiscoveredSpecFile } from '../../utils/spec-path-containment.js';
@@ -108,8 +108,10 @@ export class Validator {
108
108
  * Validate delta-formatted spec files under a change directory.
109
109
  * Enforces:
110
110
  * - At least one delta across all files
111
- * - ADDED/MODIFIED: each requirement has at least one scenario; missing
112
- * English SHALL/MUST keywords are guidance unless strict mode is enabled
111
+ * - ADDED/MODIFIED: each requirement has at least one scenario; a scenario
112
+ * with no WHEN/GIVEN or THEN step is guidance (WARNING) unless strict mode
113
+ * is enabled; missing English SHALL/MUST keywords are guidance unless
114
+ * strict mode is enabled
113
115
  * - REMOVED: names only; no scenario/description required
114
116
  * - RENAMED: pairs well-formed
115
117
  * - No duplicates within sections; no cross-section conflicts per spec
@@ -278,6 +280,7 @@ export class Validator {
278
280
  if (scenarioCount < 1) {
279
281
  issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must include at least one scenario${this.emptyScenarioHint(block.raw)}` });
280
282
  }
283
+ issues.push(...this.findScenarioStepIssues(block.raw, `ADDED "${block.name}"`, entryPath));
281
284
  }
282
285
  // Validate MODIFIED
283
286
  for (const block of plan.modified) {
@@ -310,6 +313,7 @@ export class Validator {
310
313
  if (scenarioCount < 1) {
311
314
  issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must include at least one scenario${this.emptyScenarioHint(block.raw)}` });
312
315
  }
316
+ issues.push(...this.findScenarioStepIssues(block.raw, `MODIFIED "${block.name}"`, entryPath));
313
317
  }
314
318
  // Run archive's scenario-loss check here too, so the change fails at
315
319
  // authoring time instead of days later at archive time (#1477).
@@ -698,6 +702,14 @@ export class Validator {
698
702
  };
699
703
  });
700
704
  }
705
+ /**
706
+ * Every guidance and error the main-spec path emits about one spec file
707
+ * (#1156, #243): structure, a placeholder or too-brief Purpose, requirement
708
+ * length, a requirement with no scenario, a missing English SHALL/MUST in the
709
+ * body, and a counted scenario with no trigger or outcome. The step check is
710
+ * the same one the delta path runs, on the same `#### ` blocks, so a scenario
711
+ * that asserts nothing is reported wherever it lives.
712
+ */
701
713
  applySpecRules(spec, content) {
702
714
  const issues = [];
703
715
  for (const structuralIssue of findMainSpecStructureIssues(content)) {
@@ -767,6 +779,9 @@ export class Validator {
767
779
  message: this.buildMissingShallOrMustMessage(`Requirement "${block.name}"`, block.name, true),
768
780
  });
769
781
  }
782
+ // Same step check the delta path runs, on the same blocks, so a scenario
783
+ // that asserts nothing is reported wherever it lives.
784
+ issues.push(...this.findScenarioStepIssues(block.raw, `Requirement "${block.name}"`, `requirements[${index}].scenarios`));
770
785
  });
771
786
  return issues;
772
787
  }
@@ -941,6 +956,45 @@ export class Validator {
941
956
  return '';
942
957
  return ' (a scenario header with no body under it does not count; add its steps, e.g. "- **WHEN** ..." and "- **THEN** ...")';
943
958
  }
959
+ /**
960
+ * A counted scenario whose body carries neither a **WHEN** (or **GIVEN**)
961
+ * step nor a **THEN** step can never fail: nothing triggers it and nothing
962
+ * is asserted (#2078). Read from the same blocks the scenario-loss comparison
963
+ * walks, so the two checks cannot disagree about what a scenario is.
964
+ * Only `Scenario:`-labeled blocks are checked: any level-4 child counts as a
965
+ * scenario for loss purposes, but an unlabeled `#### Notes` child is prose
966
+ * on purpose, and a missing WHEN/THEN there is noise, not a finding.
967
+ *
968
+ * A WARNING, like the missing SHALL/MUST keyword: plain `validate` and
969
+ * `archive` keep agreeing, and `--strict` — which already fails on any
970
+ * warning — is the bar that enforces it.
971
+ */
972
+ findScenarioStepIssues(blockRaw, label, issuePath) {
973
+ const issues = [];
974
+ for (const scenario of parseScenarioBlocks(blockRaw)) {
975
+ // A header with no body is already an ERROR with its own hint; a step
976
+ // complaint on top of it would say nothing new. parseScenarioBlocks keeps
977
+ // the header line in `raw`, and hasScenarioBody takes the body alone.
978
+ const bodyLines = scenario.raw.split('\n').slice(1).join('\n');
979
+ if (!hasScenarioBody(bodyLines))
980
+ continue;
981
+ if (!isScenarioLabeledHeader(scenario.raw.split('\n')[0]))
982
+ continue;
983
+ const { hasTrigger, hasOutcome } = scenarioStepCoverage(scenario.raw);
984
+ if (hasTrigger && hasOutcome)
985
+ continue;
986
+ const missing = [!hasTrigger && 'a WHEN/GIVEN step', !hasOutcome && 'a THEN step']
987
+ .filter((part) => part !== false)
988
+ .join(' and ');
989
+ issues.push({
990
+ level: 'WARNING',
991
+ path: issuePath,
992
+ message: `${label}: scenario "${scenario.name}" is missing ${missing}. A scenario needs a trigger and an outcome, e.g. "- **WHEN** an invoice is voided" and "- **THEN** a credit note is issued".`,
993
+ });
994
+ }
995
+ return issues;
996
+ }
997
+ /** Render a list of section names as prose: `a`, `a and b`, `a, b and c`. */
944
998
  formatSectionList(sections) {
945
999
  if (sections.length === 0)
946
1000
  return '';
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Called by the standalone binary's entry point. That copy has no package
3
+ * manager behind it, so a package-manager command is the wrong upgrade advice.
4
+ */
5
+ export declare function markStandaloneBinary(executablePath: string): void;
6
+ /** The standalone binary's own path, or null when running from a package. */
7
+ export declare function getStandaloneBinaryPath(): string | null;
1
8
  /**
2
9
  * The registry to ask. A configured registry is honored only over TLS: a
3
10
  * cleartext hop would aim the request at a link-local or internal address and
@@ -79,7 +86,7 @@ export declare function isNpmGlobalInstall(installDir: string | null, roots?: st
79
86
  */
80
87
  export declare function isSourceCheckout(installDir: string | null): boolean;
81
88
  export type PackageManager = 'npm' | 'pnpm' | 'bun' | 'yarn' | 'volta';
82
- export type InstallScope = 'global' | 'project' | 'temporary' | 'source';
89
+ export type InstallScope = 'global' | 'project' | 'temporary' | 'source' | 'standalone';
83
90
  export interface CliInstallInfo {
84
91
  location: string | null;
85
92
  packageManager: PackageManager | null;
@@ -3,11 +3,13 @@ import http from 'http';
3
3
  import https from 'https';
4
4
  import path from 'path';
5
5
  import { createRequire } from 'module';
6
+ import { fileURLToPath } from 'url';
6
7
  import chalk from 'chalk';
7
8
  import { isCiEnvironment } from '../utils/ci.js';
8
9
  import { isTelemetryOptedOutByEnv } from '../telemetry/opt-out.js';
9
10
  import { FileSystemUtils } from '../utils/file-system.js';
10
11
  import { getGlobalConfig, isGlobalConfigUnreadable } from './global-config.js';
12
+ import { findCopiesOnPath, samePath } from '../utils/executable-path.js';
11
13
  const require = createRequire(import.meta.url);
12
14
  const { name: PACKAGE_NAME, version: OPENSPEC_VERSION } = require('../../package.json');
13
15
  const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
@@ -22,6 +24,34 @@ const MAX_REDIRECTS = 3;
22
24
  * one could smuggle ANSI cursor controls and repaint the lines around it.
23
25
  */
24
26
  const SAFE_VERSION = /^\d{1,10}\.\d{1,10}\.\d{1,10}(?:-[0-9A-Za-z.-]{1,64})?(?:\+[0-9A-Za-z.-]{1,64})?$/;
27
+ let standaloneBinaryPath = null;
28
+ /**
29
+ * Called by the standalone binary's entry point. That copy has no package
30
+ * manager behind it, so a package-manager command is the wrong upgrade advice.
31
+ */
32
+ export function markStandaloneBinary(executablePath) {
33
+ standaloneBinaryPath = executablePath;
34
+ }
35
+ /** The standalone binary's own path, or null when running from a package. */
36
+ export function getStandaloneBinaryPath() {
37
+ return standaloneBinaryPath;
38
+ }
39
+ /**
40
+ * `openspec install` reinstalls the standalone binary where it already is,
41
+ * under its own name, so the new copy cannot end up shadowed by the old one.
42
+ * Spelled with the full path when `openspec` on PATH is some other copy.
43
+ * Null when this is not the binary.
44
+ */
45
+ function standaloneUpgradeCommand() {
46
+ if (!standaloneBinaryPath)
47
+ return null;
48
+ const first = findCopiesOnPath()[0];
49
+ if (first && samePath(first, standaloneBinaryPath))
50
+ return 'openspec install';
51
+ return process.platform === 'win32'
52
+ ? `& '${standaloneBinaryPath.replace(/'/g, "''")}' install`
53
+ : `'${standaloneBinaryPath.replace(/'/g, `'\\''`)}' install`;
54
+ }
25
55
  /**
26
56
  * The check is opt-out and must never get in the way: no network in CI or
27
57
  * tests, an explicit escape hatch for anyone offline or air-gapped, and the
@@ -304,12 +334,14 @@ export async function getAvailableCliUpdate() {
304
334
  * which copy is actually answering.
305
335
  */
306
336
  export function getInstallDir() {
307
- try {
308
- return path.dirname(require.resolve('../../package.json'));
309
- }
310
- catch {
337
+ // The binary is not a package install, so no package-manager logic applies.
338
+ if (standaloneBinaryPath)
311
339
  return null;
312
- }
340
+ // Resolved at runtime from this module's own (symlink-resolved) location.
341
+ // require.resolve would be resolved by the binary's bundler at build time,
342
+ // baking the build machine's path into it.
343
+ const packageJson = fileURLToPath(new URL('../../package.json', import.meta.url));
344
+ return fs.existsSync(packageJson) ? path.dirname(packageJson) : null;
313
345
  }
314
346
  /**
315
347
  * True when the running CLI resolves from a `node_modules` belonging to the
@@ -508,6 +540,9 @@ function detectGlobalPackageManager(installDir) {
508
540
  }
509
541
  /** Describes the running copy without guessing when its owner is ambiguous. */
510
542
  export function getCliInstallInfo(installDir = getInstallDir(), projectPath = '.') {
543
+ if (standaloneBinaryPath) {
544
+ return { location: path.dirname(standaloneBinaryPath), packageManager: null, scope: 'standalone' };
545
+ }
511
546
  if (!installDir) {
512
547
  return { location: null, packageManager: null, scope: null };
513
548
  }
@@ -537,6 +572,8 @@ export function getCliInstallInfo(installDir = getInstallDir(), projectPath = '.
537
572
  }
538
573
  /** Returns a safe update command only for an install with known global ownership. */
539
574
  export function getCliUpdateCommand(install) {
575
+ if (install.scope === 'standalone')
576
+ return standaloneUpgradeCommand();
540
577
  if (install.scope !== 'global' || install.packageManager === null)
541
578
  return null;
542
579
  return GLOBAL_UPGRADE_COMMANDS[install.packageManager];
@@ -585,6 +622,12 @@ export function buildCliUpdateLines(latestVersion, installDir, projectPath, opti
585
622
  */
586
623
  export function buildUpgradeCommandLines(installDir, projectPath) {
587
624
  const lines = [];
625
+ const standalone = standaloneUpgradeCommand();
626
+ if (standalone) {
627
+ lines.push(` ${standalone}`);
628
+ lines.push(' Then run "openspec update" again to pick up new workflows.');
629
+ return lines;
630
+ }
588
631
  if (isEphemeralRunnerInstall(installDir)) {
589
632
  // That command *is* the update, so there is nothing to run afterwards.
590
633
  lines.push(` npx ${PACKAGE_NAME}@latest update`);
@@ -0,0 +1,5 @@
1
+ /** Every `openspec` on PATH, in the order a shell would find them. */
2
+ export declare function findCopiesOnPath(pathValue?: string, platform?: NodeJS.Platform): string[];
3
+ /** Whether two paths name the same file once symlinks are resolved. */
4
+ export declare function samePath(a: string, b: string): boolean;
5
+ //# sourceMappingURL=executable-path.d.ts.map
@@ -0,0 +1,44 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ /** Every `openspec` on PATH, in the order a shell would find them. */
4
+ export function findCopiesOnPath(pathValue = process.env.PATH ?? '', platform = process.platform) {
5
+ const names = platform === 'win32' ? ['openspec.exe', 'openspec.cmd'] : ['openspec'];
6
+ const seen = new Set();
7
+ const copies = [];
8
+ for (const dir of pathValue.split(path.delimiter)) {
9
+ if (!dir)
10
+ continue;
11
+ for (const name of names) {
12
+ const candidate = path.join(dir, name);
13
+ try {
14
+ if (!fs.statSync(candidate).isFile())
15
+ continue;
16
+ if (platform !== 'win32')
17
+ fs.accessSync(candidate, fs.constants.X_OK);
18
+ const key = platform === 'win32' ? candidate.toLowerCase() : candidate;
19
+ if (!seen.has(key)) {
20
+ seen.add(key);
21
+ copies.push(candidate);
22
+ }
23
+ }
24
+ catch {
25
+ // Not there, or not runnable: not a copy a shell would run.
26
+ }
27
+ }
28
+ }
29
+ return copies;
30
+ }
31
+ /** Whether two paths name the same file once symlinks are resolved. */
32
+ export function samePath(a, b) {
33
+ const real = (value) => {
34
+ try {
35
+ return fs.realpathSync(value);
36
+ }
37
+ catch {
38
+ return path.resolve(value);
39
+ }
40
+ };
41
+ const [x, y] = [real(a), real(b)];
42
+ return process.platform === 'win32' ? x.toLowerCase() === y.toLowerCase() : x === y;
43
+ }
44
+ //# sourceMappingURL=executable-path.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fission-ai/openspec",
3
- "version": "1.14.3",
3
+ "version": "1.16.0",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",
@@ -38,19 +38,20 @@
38
38
  "!dist/**/*.map"
39
39
  ],
40
40
  "engines": {
41
- "node": ">=20.19.0"
41
+ "node": ">=22.12.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@changesets/changelog-github": "^1.0.0",
45
45
  "@changesets/cli": "^3.0.1",
46
46
  "@types/node": "^20.19.43",
47
47
  "@types/picomatch": "^4.0.3",
48
- "@vitest/ui": "^4.1.11",
48
+ "@vitest/ui": "^4.1.12",
49
49
  "eslint": "^10.5.0",
50
50
  "smol-toml": "^1.7.1",
51
51
  "typescript": "^6.0.3",
52
52
  "typescript-eslint": "^8.65.0",
53
- "vitest": "^4.1.11"
53
+ "vite": "^8.3.5",
54
+ "vitest": "^4.1.12"
54
55
  },
55
56
  "dependencies": {
56
57
  "@inquirer/core": "^12.0.0",
@@ -105,6 +105,7 @@ artifacts:
105
105
  - Each scenario: `#### Scenario: <name>` with WHEN/THEN format
106
106
  - **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
107
107
  - Every requirement MUST have at least one scenario.
108
+ - Every scenario MUST have a WHEN/GIVEN step (its trigger) and a THEN step (its outcome). A scenario with only AND lines or plain prose still counts as a scenario, but `openspec validate` flags it as a warning: normal validation still passes, but `openspec validate --strict` fails on it.
108
109
  - Keep each requirement's description (the text between `### Requirement:` and its first scenario) to 500 characters or fewer. `openspec validate` flags longer descriptions in ADDED requirements and in the main spec. This is a warning: normal validation still passes, but `openspec validate --strict` fails on it. When writing a new requirement, state one behavior per requirement: move examples and edge cases into scenarios, and split a requirement that covers several behaviors into separate `### Requirement:` blocks, each with its own scenarios. Under MODIFIED, keep the existing requirement block whole; never split, trim or rewrite existing text just to meet the length. Split an existing long requirement only when the user asks for it, in a change made for that purpose: under MODIFIED, keep its header and every scenario and cut its description down to one behavior without changing its meaning, then add each behavior you removed as its own ADDED requirement with its own scenarios.
109
110
 
110
111
  New capabilities only: the delta spec's first section is `## Purpose` -