@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.
- package/README.md +13 -1
- package/dist/commands/change.d.ts +0 -1
- package/dist/commands/change.js +6 -40
- package/dist/commands/validate.d.ts +8 -0
- package/dist/commands/validate.js +16 -4
- package/dist/core/archive.js +15 -16
- package/dist/core/command-generation/adapters/claude.d.ts +1 -1
- package/dist/core/command-generation/adapters/claude.js +3 -1
- package/dist/core/command-generation/adapters/index.d.ts +2 -0
- package/dist/core/command-generation/adapters/index.js +2 -0
- package/dist/core/command-generation/adapters/oh-my-pi.d.ts +21 -0
- package/dist/core/command-generation/adapters/oh-my-pi.js +44 -0
- package/dist/core/command-generation/adapters/trae.d.ts +13 -0
- package/dist/core/command-generation/adapters/trae.js +48 -0
- package/dist/core/command-generation/registry.js +4 -0
- package/dist/core/config.js +1 -0
- package/dist/core/init.js +3 -2
- package/dist/core/list.js +18 -9
- package/dist/core/openspec-root.js +18 -15
- package/dist/core/parsers/change-parser.js +2 -1
- package/dist/core/parsers/markdown-parser.d.ts +0 -3
- package/dist/core/parsers/markdown-parser.js +5 -59
- package/dist/core/parsers/requirement-blocks.d.ts +11 -0
- package/dist/core/parsers/requirement-blocks.js +42 -8
- package/dist/core/parsers/requirement-text.d.ts +55 -0
- package/dist/core/parsers/requirement-text.js +136 -0
- package/dist/core/profile-sync-drift.js +1 -0
- package/dist/core/profiles.d.ts +2 -2
- package/dist/core/profiles.js +2 -1
- package/dist/core/schemas/base.schema.js +7 -2
- package/dist/core/shared/allowed-tools.d.ts +12 -0
- package/dist/core/shared/allowed-tools.js +12 -0
- package/dist/core/shared/skill-generation.js +5 -1
- package/dist/core/shared/tool-detection.d.ts +2 -2
- package/dist/core/shared/tool-detection.js +2 -0
- package/dist/core/specs-apply.js +35 -1
- package/dist/core/store/operations.js +20 -0
- package/dist/core/templates/skill-templates.d.ts +1 -0
- package/dist/core/templates/skill-templates.js +1 -0
- package/dist/core/templates/workflows/update-change.d.ts +10 -0
- package/dist/core/templates/workflows/update-change.js +167 -0
- package/dist/core/update.js +4 -4
- package/dist/core/validation/validator.d.ts +7 -0
- package/dist/core/validation/validator.js +101 -38
- package/dist/core/view.js +1 -1
- package/dist/utils/task-progress.d.ts +10 -1
- package/dist/utils/task-progress.js +66 -2
- 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 =
|
|
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 =
|
|
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
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
88
|
-
const
|
|
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
|
-
|
|
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',
|
package/dist/core/profiles.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/core/profiles.js
CHANGED
|
@@ -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',
|
package/dist/core/specs-apply.js
CHANGED
|
@@ -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
|
-
|
|
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
|
*
|