@fission-ai/openspec 1.5.0 → 1.6.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 (48) hide show
  1. package/README.md +13 -1
  2. package/dist/commands/change.d.ts +0 -1
  3. package/dist/commands/change.js +6 -40
  4. package/dist/commands/validate.d.ts +8 -0
  5. package/dist/commands/validate.js +16 -4
  6. package/dist/core/archive.js +15 -16
  7. package/dist/core/command-generation/adapters/claude.d.ts +1 -1
  8. package/dist/core/command-generation/adapters/claude.js +3 -1
  9. package/dist/core/command-generation/adapters/index.d.ts +2 -0
  10. package/dist/core/command-generation/adapters/index.js +2 -0
  11. package/dist/core/command-generation/adapters/oh-my-pi.d.ts +21 -0
  12. package/dist/core/command-generation/adapters/oh-my-pi.js +44 -0
  13. package/dist/core/command-generation/adapters/trae.d.ts +13 -0
  14. package/dist/core/command-generation/adapters/trae.js +48 -0
  15. package/dist/core/command-generation/registry.js +4 -0
  16. package/dist/core/config.js +1 -0
  17. package/dist/core/init.js +3 -2
  18. package/dist/core/list.js +18 -9
  19. package/dist/core/openspec-root.js +18 -15
  20. package/dist/core/parsers/change-parser.js +2 -1
  21. package/dist/core/parsers/markdown-parser.d.ts +0 -3
  22. package/dist/core/parsers/markdown-parser.js +5 -59
  23. package/dist/core/parsers/requirement-blocks.d.ts +11 -0
  24. package/dist/core/parsers/requirement-blocks.js +42 -8
  25. package/dist/core/parsers/requirement-text.d.ts +55 -0
  26. package/dist/core/parsers/requirement-text.js +136 -0
  27. package/dist/core/profile-sync-drift.js +1 -0
  28. package/dist/core/profiles.d.ts +2 -2
  29. package/dist/core/profiles.js +2 -1
  30. package/dist/core/schemas/base.schema.js +7 -2
  31. package/dist/core/shared/allowed-tools.d.ts +12 -0
  32. package/dist/core/shared/allowed-tools.js +12 -0
  33. package/dist/core/shared/skill-generation.js +5 -1
  34. package/dist/core/shared/tool-detection.d.ts +2 -2
  35. package/dist/core/shared/tool-detection.js +2 -0
  36. package/dist/core/specs-apply.js +35 -1
  37. package/dist/core/store/operations.js +20 -0
  38. package/dist/core/templates/skill-templates.d.ts +1 -0
  39. package/dist/core/templates/skill-templates.js +1 -0
  40. package/dist/core/templates/workflows/update-change.d.ts +10 -0
  41. package/dist/core/templates/workflows/update-change.js +167 -0
  42. package/dist/core/update.js +4 -4
  43. package/dist/core/validation/validator.d.ts +7 -0
  44. package/dist/core/validation/validator.js +101 -38
  45. package/dist/core/view.js +1 -1
  46. package/dist/utils/task-progress.d.ts +10 -1
  47. package/dist/utils/task-progress.js +66 -2
  48. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { MarkdownParser } from './markdown-parser.js';
2
+ import { buildCodeFenceMask } from './requirement-text.js';
2
3
  import path from 'path';
3
4
  import { promises as fs } from 'fs';
4
5
  export class ChangeParser extends MarkdownParser {
@@ -148,7 +149,7 @@ export class ChangeParser extends MarkdownParser {
148
149
  parseSectionsFromContent(content) {
149
150
  const normalizedContent = ChangeParser.normalizeContent(content);
150
151
  const lines = normalizedContent.split('\n');
151
- const codeFenceLineMask = ChangeParser.buildCodeFenceMask(lines);
152
+ const codeFenceLineMask = buildCodeFenceMask(lines);
152
153
  const sections = [];
153
154
  const stack = [];
154
155
  for (let i = 0; i < lines.length; i++) {
@@ -11,9 +11,6 @@ export declare class MarkdownParser {
11
11
  private currentLine;
12
12
  constructor(content: string);
13
13
  protected static normalizeContent(content: string): string;
14
- protected static buildCodeFenceMask(lines: string[]): boolean[];
15
- private static getFenceMarker;
16
- private static isClosingFence;
17
14
  parseSpec(name: string): Spec;
18
15
  parseChange(name: string): Change;
19
16
  protected parseSections(): Section[];
@@ -1,3 +1,4 @@
1
+ import { buildCodeFenceMask, extractRequirementText } from './requirement-text.js';
1
2
  export class MarkdownParser {
2
3
  lines;
3
4
  codeFenceLineMask;
@@ -5,47 +6,12 @@ export class MarkdownParser {
5
6
  constructor(content) {
6
7
  const normalized = MarkdownParser.normalizeContent(content);
7
8
  this.lines = normalized.split('\n');
8
- this.codeFenceLineMask = MarkdownParser.buildCodeFenceMask(this.lines);
9
+ this.codeFenceLineMask = buildCodeFenceMask(this.lines);
9
10
  this.currentLine = 0;
10
11
  }
11
12
  static normalizeContent(content) {
12
13
  return content.replace(/\r\n?/g, '\n');
13
14
  }
14
- static buildCodeFenceMask(lines) {
15
- const mask = new Array(lines.length).fill(false);
16
- let activeFence = null;
17
- for (let i = 0; i < lines.length; i++) {
18
- const fence = MarkdownParser.getFenceMarker(lines[i]);
19
- if (!activeFence) {
20
- if (fence) {
21
- activeFence = fence;
22
- mask[i] = true;
23
- }
24
- continue;
25
- }
26
- mask[i] = true;
27
- if (MarkdownParser.isClosingFence(lines[i], activeFence)) {
28
- activeFence = null;
29
- }
30
- }
31
- return mask;
32
- }
33
- static getFenceMarker(line) {
34
- const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
35
- if (!fenceMatch) {
36
- return null;
37
- }
38
- return {
39
- marker: fenceMatch[1][0],
40
- length: fenceMatch[1].length,
41
- };
42
- }
43
- static isClosingFence(line, activeFence) {
44
- const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/);
45
- return Boolean(fenceMatch &&
46
- fenceMatch[1][0] === activeFence.marker &&
47
- fenceMatch[1].length >= activeFence.length);
48
- }
49
15
  parseSpec(name) {
50
16
  const sections = this.parseSections();
51
17
  const purpose = this.findSection(sections, 'Purpose')?.content || '';
@@ -149,29 +115,9 @@ export class MarkdownParser {
149
115
  parseRequirements(section) {
150
116
  const requirements = [];
151
117
  for (const child of section.children) {
152
- // Extract requirement text from first non-empty content line, fall back to heading
153
- let text = child.title;
154
- // Get content before any child sections (scenarios)
155
- if (child.content.trim()) {
156
- // Split content into lines and find content before any child headers
157
- const lines = child.content.split('\n');
158
- const contentBeforeChildren = [];
159
- for (const line of lines) {
160
- // Stop at child headers (scenarios start with ####)
161
- if (line.trim().startsWith('#')) {
162
- break;
163
- }
164
- contentBeforeChildren.push(line);
165
- }
166
- // Find first non-empty line
167
- const directContent = contentBeforeChildren.join('\n').trim();
168
- if (directContent) {
169
- const firstLine = directContent.split('\n').find(l => l.trim());
170
- if (firstLine) {
171
- text = firstLine.trim();
172
- }
173
- }
174
- }
118
+ // Read the requirement text via the shared reader (multi-line, fence- and
119
+ // metadata-aware, with the shared header-title fallback for empty bodies).
120
+ const text = extractRequirementText(child.title, child.content.split('\n'));
175
121
  const scenarios = this.parseScenarios(child);
176
122
  requirements.push({
177
123
  text,
@@ -15,6 +15,16 @@ export declare function normalizeRequirementName(name: string): string;
15
15
  * Extracts the Requirements section from a spec file and parses requirement blocks.
16
16
  */
17
17
  export declare function extractRequirementsSection(content: string): RequirementsSectionParts;
18
+ /**
19
+ * A level-3 header inside `## ADDED`/`## MODIFIED Requirements` that is not a
20
+ * canonical `### Requirement:` header, recorded at the moment the delta reader
21
+ * skips over it. Surfaced as an INFO note by `validate <change>` (#498).
22
+ */
23
+ export interface SkippedHeader {
24
+ header: string;
25
+ section: string;
26
+ line: number;
27
+ }
18
28
  export interface DeltaPlan {
19
29
  added: RequirementBlock[];
20
30
  modified: RequirementBlock[];
@@ -23,6 +33,7 @@ export interface DeltaPlan {
23
33
  from: string;
24
34
  to: string;
25
35
  }>;
36
+ skippedHeaders: SkippedHeader[];
26
37
  sectionPresence: {
27
38
  added: boolean;
28
39
  modified: boolean;
@@ -1,6 +1,8 @@
1
+ import { buildCodeFenceMask } from './requirement-text.js';
1
2
  export function normalizeRequirementName(name) {
2
3
  return name.trim();
3
4
  }
5
+ /** The canonical requirement header the delta reader recognizes. */
4
6
  const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/i;
5
7
  /**
6
8
  * Extracts the Requirements section from a spec file and parses requirement blocks.
@@ -84,15 +86,26 @@ export function parseDeltaSpec(content) {
84
86
  const modifiedLookup = getSectionCaseInsensitive(sections, 'MODIFIED Requirements');
85
87
  const removedLookup = getSectionCaseInsensitive(sections, 'REMOVED Requirements');
86
88
  const renamedLookup = getSectionCaseInsensitive(sections, 'RENAMED Requirements');
87
- const added = parseRequirementBlocksFromSection(addedLookup.body);
88
- const modified = parseRequirementBlocksFromSection(modifiedLookup.body);
89
+ const skippedHeaders = [];
90
+ const added = parseRequirementBlocksFromSection(addedLookup.body, {
91
+ section: addedLookup.title,
92
+ bodyStartLine: addedLookup.bodyStartLine,
93
+ sink: skippedHeaders,
94
+ });
95
+ const modified = parseRequirementBlocksFromSection(modifiedLookup.body, {
96
+ section: modifiedLookup.title,
97
+ bodyStartLine: modifiedLookup.bodyStartLine,
98
+ sink: skippedHeaders,
99
+ });
89
100
  const removedNames = parseRemovedNames(removedLookup.body);
90
101
  const renamedPairs = parseRenamedPairs(renamedLookup.body);
102
+ skippedHeaders.sort((a, b) => a.line - b.line);
91
103
  return {
92
104
  added,
93
105
  modified,
94
106
  removed: removedNames,
95
107
  renamed: renamedPairs,
108
+ skippedHeaders,
96
109
  sectionPresence: {
97
110
  added: addedLookup.found,
98
111
  modified: modifiedLookup.found,
@@ -116,28 +129,48 @@ function splitTopLevelSections(content) {
116
129
  const current = indices[i];
117
130
  const next = indices[i + 1];
118
131
  const body = lines.slice(current.index + 1, next ? next.index : lines.length).join('\n');
119
- result[current.title] = body;
132
+ // First body line, 1-based: the header is at 0-based current.index.
133
+ result[current.title] = { body, bodyStartLine: current.index + 2 };
120
134
  }
121
135
  return result;
122
136
  }
123
137
  function getSectionCaseInsensitive(sections, desired) {
124
138
  const target = desired.toLowerCase();
125
- for (const [title, body] of Object.entries(sections)) {
139
+ for (const [title, { body, bodyStartLine }] of Object.entries(sections)) {
126
140
  if (title.toLowerCase() === target)
127
- return { body, found: true };
141
+ return { title, body, bodyStartLine, found: true };
128
142
  }
129
- return { body: '', found: false };
143
+ return { title: desired, body: '', bodyStartLine: 0, found: false };
130
144
  }
131
- function parseRequirementBlocksFromSection(sectionBody) {
145
+ function parseRequirementBlocksFromSection(sectionBody, skipped) {
132
146
  if (!sectionBody)
133
147
  return [];
134
148
  const lines = normalizeLineEndings(sectionBody).split('\n');
149
+ // Record the non-canonical level-3 headers this reader skips, at the moment
150
+ // it skips them, so the INFO note describes the reader's real boundaries.
151
+ // Fence-masked lines are excluded: the body reader treats them as fenced
152
+ // content, not as headers.
153
+ const fenceMask = skipped ? buildCodeFenceMask(lines) : undefined;
154
+ const recordIfSkippedHeader = (index) => {
155
+ if (!skipped || fenceMask[index])
156
+ return;
157
+ const h3 = lines[index].match(/^###\s+(.+?)\s*$/);
158
+ if (h3 && !REQUIREMENT_HEADER_REGEX.test(lines[index])) {
159
+ skipped.sink.push({
160
+ header: h3[1].trim(),
161
+ section: skipped.section,
162
+ line: skipped.bodyStartLine + index,
163
+ });
164
+ }
165
+ };
135
166
  const blocks = [];
136
167
  let i = 0;
137
168
  while (i < lines.length) {
138
169
  // Seek next requirement header
139
- while (i < lines.length && !REQUIREMENT_HEADER_REGEX.test(lines[i]))
170
+ while (i < lines.length && !REQUIREMENT_HEADER_REGEX.test(lines[i])) {
171
+ recordIfSkippedHeader(i);
140
172
  i++;
173
+ }
141
174
  if (i >= lines.length)
142
175
  break;
143
176
  const headerLine = lines[i];
@@ -150,6 +183,7 @@ function parseRequirementBlocksFromSection(sectionBody) {
150
183
  const buf = [headerLine];
151
184
  i++;
152
185
  while (i < lines.length && !REQUIREMENT_HEADER_REGEX.test(lines[i]) && !/^##\s+/.test(lines[i])) {
186
+ recordIfSkippedHeader(i);
153
187
  buf.push(lines[i]);
154
188
  i++;
155
189
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Shared, fence-aware requirement-reading helpers.
3
+ *
4
+ * The requirement reader used to be implemented twice — once for main specs
5
+ * (`MarkdownParser.parseRequirements`) and once for change deltas
6
+ * (`Validator.extractRequirementText` / `countScenarios`) — and the two drifted
7
+ * apart. These helpers are the single source of truth for requirement-body
8
+ * extraction, scenario counting, and `SHALL`/`MUST` detection in
9
+ * `validate <change>`, `validate <spec>`, and `archive`.
10
+ */
11
+ /**
12
+ * Build a per-line mask marking lines that fall inside a fenced code block
13
+ * (``` ``` ``` or ``` ~~~ ```), including the fence lines themselves. Mirrors the
14
+ * fence rules markdown uses: a fence opens on the first ```` ```/~~~ ```` of
15
+ * length >= 3 and closes on a line of the same marker whose length is >= the
16
+ * opening length, with nothing but whitespace after it.
17
+ */
18
+ export declare function buildCodeFenceMask(lines: string[]): boolean[];
19
+ /**
20
+ * The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
21
+ * as whole words so the change-delta reader and the schema-based reader accept
22
+ * and reject identical text.
23
+ */
24
+ export declare function containsShallOrMust(text: string): boolean;
25
+ /**
26
+ * Extract the full requirement body from the lines that follow a
27
+ * `### Requirement:` header (the lines may include scenarios and fenced code).
28
+ *
29
+ * Captures every body line from the start up to the first header found on a
30
+ * non-fenced line — usually the first `#### Scenario:`, but also a stray `###`
31
+ * divider the delta reader absorbed into the block — skipping blank lines and
32
+ * any line inside a fenced code block. `**metadata**:` lines are skipped only
33
+ * when other body text remains: a requirement written entirely as
34
+ * `**Constraint**: The system MUST ...` keeps that line as its body. Captured
35
+ * lines are trimmed and joined with newlines so a requirement whose text wraps
36
+ * across lines — or whose `SHALL`/`MUST` lands on a later line — is read in
37
+ * full.
38
+ */
39
+ export declare function extractRequirementBody(bodyLines: string[]): string;
40
+ /**
41
+ * Parser/display fallback for a requirement block with no body text. This is
42
+ * what lets a bare `### The system SHALL ...` header remain readable on the
43
+ * spec path (the title is the requirement). Validator body-keyword checks for
44
+ * canonical `### Requirement:` blocks use `extractRequirementBody` directly so
45
+ * a keyword that appears only in the header still receives the #1156/#1280
46
+ * body-keyword hint.
47
+ */
48
+ export declare function extractRequirementText(headerTitle: string, bodyLines: string[]): string;
49
+ /**
50
+ * Count the real scenarios in a requirement block: `#### ` headers on non-fenced
51
+ * lines. A `#### Scenario:` that lives inside a fenced example is not a real
52
+ * scenario and is not counted.
53
+ */
54
+ export declare function countScenarios(bodyLines: string[]): number;
55
+ //# sourceMappingURL=requirement-text.d.ts.map
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Shared, fence-aware requirement-reading helpers.
3
+ *
4
+ * The requirement reader used to be implemented twice — once for main specs
5
+ * (`MarkdownParser.parseRequirements`) and once for change deltas
6
+ * (`Validator.extractRequirementText` / `countScenarios`) — and the two drifted
7
+ * apart. These helpers are the single source of truth for requirement-body
8
+ * extraction, scenario counting, and `SHALL`/`MUST` detection in
9
+ * `validate <change>`, `validate <spec>`, and `archive`.
10
+ */
11
+ /**
12
+ * Build a per-line mask marking lines that fall inside a fenced code block
13
+ * (``` ``` ``` or ``` ~~~ ```), including the fence lines themselves. Mirrors the
14
+ * fence rules markdown uses: a fence opens on the first ```` ```/~~~ ```` of
15
+ * length >= 3 and closes on a line of the same marker whose length is >= the
16
+ * opening length, with nothing but whitespace after it.
17
+ */
18
+ export function buildCodeFenceMask(lines) {
19
+ const mask = new Array(lines.length).fill(false);
20
+ let activeFence = null;
21
+ for (let i = 0; i < lines.length; i++) {
22
+ const fence = getFenceMarker(lines[i]);
23
+ if (!activeFence) {
24
+ if (fence) {
25
+ activeFence = fence;
26
+ mask[i] = true;
27
+ }
28
+ continue;
29
+ }
30
+ mask[i] = true;
31
+ if (isClosingFence(lines[i], activeFence)) {
32
+ activeFence = null;
33
+ }
34
+ }
35
+ return mask;
36
+ }
37
+ function getFenceMarker(line) {
38
+ const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
39
+ if (!fenceMatch) {
40
+ return null;
41
+ }
42
+ return {
43
+ marker: fenceMatch[1][0],
44
+ length: fenceMatch[1].length,
45
+ };
46
+ }
47
+ function isClosingFence(line, activeFence) {
48
+ const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/);
49
+ return Boolean(fenceMatch &&
50
+ fenceMatch[1][0] === activeFence.marker &&
51
+ fenceMatch[1].length >= activeFence.length);
52
+ }
53
+ /** Lines that look like `**ID**: ...` / `**Priority**: ...` metadata. */
54
+ const METADATA_LINE = /^\*\*[^*]+\*\*:/;
55
+ /** Any markdown header line — the boundary where a requirement body ends. */
56
+ const HEADER_LINE = /^#{1,6}\s/;
57
+ /**
58
+ * A level-4 header. Deliberately matches ANY `####` header, not only
59
+ * `#### Scenario:` — the spec path treats every level-4 child of a requirement
60
+ * as a scenario, so the delta counter must too (parity). Don't tighten this to
61
+ * `Scenario:` without changing both paths together.
62
+ */
63
+ const SCENARIO_HEADER = /^####\s+/;
64
+ /**
65
+ * The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
66
+ * as whole words so the change-delta reader and the schema-based reader accept
67
+ * and reject identical text.
68
+ */
69
+ export function containsShallOrMust(text) {
70
+ return /\b(SHALL|MUST)\b/.test(text);
71
+ }
72
+ /**
73
+ * Extract the full requirement body from the lines that follow a
74
+ * `### Requirement:` header (the lines may include scenarios and fenced code).
75
+ *
76
+ * Captures every body line from the start up to the first header found on a
77
+ * non-fenced line — usually the first `#### Scenario:`, but also a stray `###`
78
+ * divider the delta reader absorbed into the block — skipping blank lines and
79
+ * any line inside a fenced code block. `**metadata**:` lines are skipped only
80
+ * when other body text remains: a requirement written entirely as
81
+ * `**Constraint**: The system MUST ...` keeps that line as its body. Captured
82
+ * lines are trimmed and joined with newlines so a requirement whose text wraps
83
+ * across lines — or whose `SHALL`/`MUST` lands on a later line — is read in
84
+ * full.
85
+ */
86
+ export function extractRequirementBody(bodyLines) {
87
+ const mask = buildCodeFenceMask(bodyLines);
88
+ const captured = [];
89
+ const metadata = [];
90
+ for (let i = 0; i < bodyLines.length; i++) {
91
+ if (mask[i])
92
+ continue; // inside a fenced code block
93
+ const line = bodyLines[i];
94
+ if (HEADER_LINE.test(line))
95
+ break; // first scenario or stray divider
96
+ const trimmed = line.trim();
97
+ if (trimmed.length === 0)
98
+ continue; // blank
99
+ if (METADATA_LINE.test(trimmed)) {
100
+ metadata.push(trimmed); // **ID**: / **Priority**: ...
101
+ continue;
102
+ }
103
+ captured.push(trimmed);
104
+ }
105
+ if (captured.length > 0)
106
+ return captured.join('\n');
107
+ return metadata.join('\n'); // metadata-only body: the metadata IS the body
108
+ }
109
+ /**
110
+ * Parser/display fallback for a requirement block with no body text. This is
111
+ * what lets a bare `### The system SHALL ...` header remain readable on the
112
+ * spec path (the title is the requirement). Validator body-keyword checks for
113
+ * canonical `### Requirement:` blocks use `extractRequirementBody` directly so
114
+ * a keyword that appears only in the header still receives the #1156/#1280
115
+ * body-keyword hint.
116
+ */
117
+ export function extractRequirementText(headerTitle, bodyLines) {
118
+ return extractRequirementBody(bodyLines) || headerTitle.trim();
119
+ }
120
+ /**
121
+ * Count the real scenarios in a requirement block: `#### ` headers on non-fenced
122
+ * lines. A `#### Scenario:` that lives inside a fenced example is not a real
123
+ * scenario and is not counted.
124
+ */
125
+ export function countScenarios(bodyLines) {
126
+ const mask = buildCodeFenceMask(bodyLines);
127
+ let count = 0;
128
+ for (let i = 0; i < bodyLines.length; i++) {
129
+ if (mask[i])
130
+ continue;
131
+ if (SCENARIO_HEADER.test(bodyLines[i]))
132
+ count++;
133
+ }
134
+ return count;
135
+ }
136
+ //# sourceMappingURL=requirement-text.js.map
@@ -12,6 +12,7 @@ export const WORKFLOW_TO_SKILL_DIR = {
12
12
  'new': 'openspec-new-change',
13
13
  'continue': 'openspec-continue-change',
14
14
  'apply': 'openspec-apply-change',
15
+ 'update': 'openspec-update-change',
15
16
  'ff': 'openspec-ff-change',
16
17
  'sync': 'openspec-sync-specs',
17
18
  'archive': 'openspec-archive-change',
@@ -9,11 +9,11 @@ import type { Profile } from './global-config.js';
9
9
  * Core workflows included in the 'core' profile.
10
10
  * These provide the streamlined experience for new users.
11
11
  */
12
- export declare const CORE_WORKFLOWS: readonly ["propose", "explore", "apply", "sync", "archive"];
12
+ export declare const CORE_WORKFLOWS: readonly ["propose", "explore", "apply", "update", "sync", "archive"];
13
13
  /**
14
14
  * All available workflows in the system.
15
15
  */
16
- export declare const ALL_WORKFLOWS: readonly ["propose", "explore", "new", "continue", "apply", "ff", "sync", "archive", "bulk-archive", "verify", "onboard"];
16
+ export declare const ALL_WORKFLOWS: readonly ["propose", "explore", "new", "continue", "apply", "update", "ff", "sync", "archive", "bulk-archive", "verify", "onboard"];
17
17
  export type WorkflowId = (typeof ALL_WORKFLOWS)[number];
18
18
  export type CoreWorkflowId = (typeof CORE_WORKFLOWS)[number];
19
19
  /**
@@ -8,7 +8,7 @@
8
8
  * Core workflows included in the 'core' profile.
9
9
  * These provide the streamlined experience for new users.
10
10
  */
11
- export const CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'sync', 'archive'];
11
+ export const CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'update', 'sync', 'archive'];
12
12
  /**
13
13
  * All available workflows in the system.
14
14
  */
@@ -18,6 +18,7 @@ export const ALL_WORKFLOWS = [
18
18
  'new',
19
19
  'continue',
20
20
  'apply',
21
+ 'update',
21
22
  'ff',
22
23
  'sync',
23
24
  'archive',
@@ -4,9 +4,14 @@ export const ScenarioSchema = z.object({
4
4
  rawText: z.string().min(1, VALIDATION_MESSAGES.SCENARIO_EMPTY),
5
5
  });
6
6
  export const RequirementSchema = z.object({
7
+ // SHALL/MUST body-keyword enforcement lives in the imperative validator
8
+ // (Validator.applySpecRules), not here: the parser collapses the requirement
9
+ // header into `text`, so a Zod refine on `text` cannot tell "keyword in header
10
+ // only" from "keyword in body" and emits a misleading generic error. The
11
+ // validator recovers the header and emits the targeted hint for both the
12
+ // main-spec and change-delta paths (#1156).
7
13
  text: z.string()
8
- .min(1, VALIDATION_MESSAGES.REQUIREMENT_EMPTY)
9
- .refine((text) => text.includes('SHALL') || text.includes('MUST'), VALIDATION_MESSAGES.REQUIREMENT_NO_SHALL),
14
+ .min(1, VALIDATION_MESSAGES.REQUIREMENT_EMPTY),
10
15
  scenarios: z.array(ScenarioSchema)
11
16
  .min(1, VALIDATION_MESSAGES.REQUIREMENT_NO_SCENARIOS),
12
17
  });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Pre-approved tools for generated skills and slash commands, emitted as the
3
+ * `allowed-tools` frontmatter field (Agent Skills standard for SKILL.md;
4
+ * same field for Claude Code slash commands). Scoped to the OpenSpec CLI so
5
+ * agents that honor it stop prompting on each `openspec` call; the field
6
+ * only pre-approves — it does not restrict — so any other tool a skill or
7
+ * command needs (Read, Write, arbitrary Bash for builds/tests) stays
8
+ * available under the user's normal permission settings. Tools that don't
9
+ * recognize the field ignore it.
10
+ */
11
+ export declare const OPENSPEC_CLI_ALLOWED_TOOLS = "Bash(openspec:*)";
12
+ //# sourceMappingURL=allowed-tools.d.ts.map
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Pre-approved tools for generated skills and slash commands, emitted as the
3
+ * `allowed-tools` frontmatter field (Agent Skills standard for SKILL.md;
4
+ * same field for Claude Code slash commands). Scoped to the OpenSpec CLI so
5
+ * agents that honor it stop prompting on each `openspec` call; the field
6
+ * only pre-approves — it does not restrict — so any other tool a skill or
7
+ * command needs (Read, Write, arbitrary Bash for builds/tests) stays
8
+ * available under the user's normal permission settings. Tools that don't
9
+ * recognize the field ignore it.
10
+ */
11
+ export const OPENSPEC_CLI_ALLOWED_TOOLS = 'Bash(openspec:*)';
12
+ //# sourceMappingURL=allowed-tools.js.map
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Shared utilities for generating skill and command files.
5
5
  */
6
- import { getExploreSkillTemplate, getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, getFfChangeSkillTemplate, getSyncSpecsSkillTemplate, getArchiveChangeSkillTemplate, getBulkArchiveChangeSkillTemplate, getVerifyChangeSkillTemplate, getOnboardSkillTemplate, getOpsxProposeSkillTemplate, getOpsxExploreCommandTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate, getOpsxFfCommandTemplate, getOpsxSyncCommandTemplate, getOpsxArchiveCommandTemplate, getOpsxBulkArchiveCommandTemplate, getOpsxVerifyCommandTemplate, getOpsxOnboardCommandTemplate, getOpsxProposeCommandTemplate, } from '../templates/skill-templates.js';
6
+ import { getExploreSkillTemplate, getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, getUpdateChangeSkillTemplate, getFfChangeSkillTemplate, getSyncSpecsSkillTemplate, getArchiveChangeSkillTemplate, getBulkArchiveChangeSkillTemplate, getVerifyChangeSkillTemplate, getOnboardSkillTemplate, getOpsxProposeSkillTemplate, getOpsxExploreCommandTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate, getOpsxUpdateCommandTemplate, getOpsxFfCommandTemplate, getOpsxSyncCommandTemplate, getOpsxArchiveCommandTemplate, getOpsxBulkArchiveCommandTemplate, getOpsxVerifyCommandTemplate, getOpsxOnboardCommandTemplate, getOpsxProposeCommandTemplate, } from '../templates/skill-templates.js';
7
+ import { OPENSPEC_CLI_ALLOWED_TOOLS } from './allowed-tools.js';
7
8
  /**
8
9
  * Gets skill templates with their directory names, optionally filtered by workflow IDs.
9
10
  *
@@ -15,6 +16,7 @@ export function getSkillTemplates(workflowFilter) {
15
16
  { template: getNewChangeSkillTemplate(), dirName: 'openspec-new-change', workflowId: 'new' },
16
17
  { template: getContinueChangeSkillTemplate(), dirName: 'openspec-continue-change', workflowId: 'continue' },
17
18
  { template: getApplyChangeSkillTemplate(), dirName: 'openspec-apply-change', workflowId: 'apply' },
19
+ { template: getUpdateChangeSkillTemplate(), dirName: 'openspec-update-change', workflowId: 'update' },
18
20
  { template: getFfChangeSkillTemplate(), dirName: 'openspec-ff-change', workflowId: 'ff' },
19
21
  { template: getSyncSpecsSkillTemplate(), dirName: 'openspec-sync-specs', workflowId: 'sync' },
20
22
  { template: getArchiveChangeSkillTemplate(), dirName: 'openspec-archive-change', workflowId: 'archive' },
@@ -39,6 +41,7 @@ export function getCommandTemplates(workflowFilter) {
39
41
  { template: getOpsxNewCommandTemplate(), id: 'new' },
40
42
  { template: getOpsxContinueCommandTemplate(), id: 'continue' },
41
43
  { template: getOpsxApplyCommandTemplate(), id: 'apply' },
44
+ { template: getOpsxUpdateCommandTemplate(), id: 'update' },
42
45
  { template: getOpsxFfCommandTemplate(), id: 'ff' },
43
46
  { template: getOpsxSyncCommandTemplate(), id: 'sync' },
44
47
  { template: getOpsxArchiveCommandTemplate(), id: 'archive' },
@@ -82,6 +85,7 @@ export function generateSkillContent(template, generatedByVersion, transformInst
82
85
  return `---
83
86
  name: ${template.name}
84
87
  description: ${template.description}
88
+ allowed-tools: ${OPENSPEC_CLI_ALLOWED_TOOLS}
85
89
  license: ${template.license || 'MIT'}
86
90
  compatibility: ${template.compatibility || 'Requires openspec CLI.'}
87
91
  metadata:
@@ -6,12 +6,12 @@
6
6
  /**
7
7
  * Names of skill directories created by openspec init.
8
8
  */
9
- export declare const SKILL_NAMES: readonly ["openspec-explore", "openspec-new-change", "openspec-continue-change", "openspec-apply-change", "openspec-ff-change", "openspec-sync-specs", "openspec-archive-change", "openspec-bulk-archive-change", "openspec-verify-change", "openspec-onboard", "openspec-propose"];
9
+ export declare const SKILL_NAMES: readonly ["openspec-explore", "openspec-new-change", "openspec-continue-change", "openspec-apply-change", "openspec-update-change", "openspec-ff-change", "openspec-sync-specs", "openspec-archive-change", "openspec-bulk-archive-change", "openspec-verify-change", "openspec-onboard", "openspec-propose"];
10
10
  export type SkillName = (typeof SKILL_NAMES)[number];
11
11
  /**
12
12
  * IDs of command templates created by openspec init.
13
13
  */
14
- export declare const COMMAND_IDS: readonly ["explore", "new", "continue", "apply", "ff", "sync", "archive", "bulk-archive", "verify", "onboard", "propose"];
14
+ export declare const COMMAND_IDS: readonly ["explore", "new", "continue", "apply", "update", "ff", "sync", "archive", "bulk-archive", "verify", "onboard", "propose"];
15
15
  export type CommandId = (typeof COMMAND_IDS)[number];
16
16
  /**
17
17
  * Status of skill configuration for a tool.
@@ -14,6 +14,7 @@ export const SKILL_NAMES = [
14
14
  'openspec-new-change',
15
15
  'openspec-continue-change',
16
16
  'openspec-apply-change',
17
+ 'openspec-update-change',
17
18
  'openspec-ff-change',
18
19
  'openspec-sync-specs',
19
20
  'openspec-archive-change',
@@ -30,6 +31,7 @@ export const COMMAND_IDS = [
30
31
  'new',
31
32
  'continue',
32
33
  'apply',
34
+ 'update',
33
35
  'ff',
34
36
  'sync',
35
37
  'archive',
@@ -207,7 +207,8 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
207
207
  // MODIFIED
208
208
  for (const mod of plan.modified) {
209
209
  const key = normalizeRequirementName(mod.name);
210
- if (!nameToBlock.has(key)) {
210
+ const currentBlock = nameToBlock.get(key);
211
+ if (!currentBlock) {
211
212
  throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`);
212
213
  }
213
214
  // Replace block with provided raw (ensure header line matches key)
@@ -215,6 +216,10 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
215
216
  if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) {
216
217
  throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`);
217
218
  }
219
+ const missingScenarios = findMissingCurrentScenarios(currentBlock, mod);
220
+ if (missingScenarios.length > 0) {
221
+ throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${missingScenarios.map(name => `"${name}"`).join(', ')}. Refresh the change spec before archiving to avoid dropping scenarios.`);
222
+ }
218
223
  nameToBlock.set(key, mod);
219
224
  }
220
225
  // ADDED
@@ -290,6 +295,35 @@ export function buildSpecSkeleton(specFolderName, changeName) {
290
295
  const titleBase = specFolderName;
291
296
  return `# ${titleBase} Specification\n\n## Purpose\nTBD - created by archiving change ${changeName}. Update Purpose after archive.\n\n## Requirements\n`;
292
297
  }
298
+ function findMissingCurrentScenarios(current, incoming) {
299
+ const incomingScenarioNames = new Set(parseScenarioBlocks(incoming.raw).map((scenario) => scenario.name));
300
+ return parseScenarioBlocks(current.raw)
301
+ .filter((scenario) => !incomingScenarioNames.has(scenario.name))
302
+ .map((scenario) => scenario.name);
303
+ }
304
+ function parseScenarioBlocks(requirementRaw) {
305
+ const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n');
306
+ const scenarios = [];
307
+ let index = 0;
308
+ while (index < lines.length) {
309
+ const headerMatch = lines[index].match(/^####\s*Scenario:\s*(.+)\s*$/);
310
+ if (!headerMatch) {
311
+ index++;
312
+ continue;
313
+ }
314
+ const start = index;
315
+ const name = headerMatch[1].trim();
316
+ index++;
317
+ while (index < lines.length && !/^####\s*Scenario:\s*(.+)\s*$/.test(lines[index])) {
318
+ index++;
319
+ }
320
+ scenarios.push({
321
+ name,
322
+ raw: lines.slice(start, index).join('\n').trimEnd(),
323
+ });
324
+ }
325
+ return scenarios;
326
+ }
293
327
  /**
294
328
  * Apply all delta specs from a change to main specs.
295
329
  *