@fission-ai/openspec 1.12.0 → 1.13.1
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 +3 -15
- package/dist/commands/change.js +12 -1
- package/dist/commands/config.d.ts +9 -0
- package/dist/commands/config.js +161 -22
- package/dist/commands/feedback.js +6 -4
- package/dist/commands/schema.js +30 -9
- package/dist/commands/store.js +5 -2
- package/dist/commands/validate.d.ts +7 -0
- package/dist/commands/validate.js +63 -1
- package/dist/commands/workflow/instructions.js +170 -16
- package/dist/commands/workflow/new-change.js +20 -0
- package/dist/commands/workflow/shared.d.ts +8 -0
- package/dist/commands/workflow/shared.js +8 -0
- package/dist/commands/workflow/status.d.ts +5 -1
- package/dist/commands/workflow/status.js +42 -5
- package/dist/core/archive.js +17 -1
- package/dist/core/artifact-graph/schema.d.ts +14 -0
- package/dist/core/artifact-graph/schema.js +52 -0
- package/dist/core/artifact-graph/types.js +12 -1
- package/dist/core/change-status-policy.d.ts +14 -0
- package/dist/core/change-status-policy.js +14 -6
- package/dist/core/command-generation/generator.js +2 -0
- package/dist/core/completion-tip.js +7 -7
- package/dist/core/completions/installers/bash-installer.js +15 -7
- package/dist/core/completions/installers/shell-quote.d.ts +11 -0
- package/dist/core/completions/installers/shell-quote.js +13 -0
- package/dist/core/completions/installers/zsh-installer.js +3 -2
- package/dist/core/config-schema.d.ts +1 -1
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +27 -1
- package/dist/core/global-config.d.ts +30 -2
- package/dist/core/global-config.js +57 -3
- package/dist/core/init.js +33 -3
- package/dist/core/legacy-cleanup.d.ts +4 -0
- package/dist/core/legacy-cleanup.js +180 -12
- package/dist/core/list.js +55 -11
- package/dist/core/migration.js +6 -1
- package/dist/core/onboarding-commands.d.ts +11 -1
- package/dist/core/onboarding-commands.js +26 -0
- package/dist/core/parsers/change-parser.d.ts +12 -1
- package/dist/core/parsers/change-parser.js +85 -87
- package/dist/core/parsers/markdown-parser.js +4 -3
- package/dist/core/parsers/requirement-blocks.d.ts +44 -3
- package/dist/core/parsers/requirement-blocks.js +164 -43
- package/dist/core/parsers/requirement-text.d.ts +13 -2
- package/dist/core/parsers/requirement-text.js +34 -7
- package/dist/core/parsers/spec-structure.js +4 -1
- package/dist/core/project-config.d.ts +9 -1
- package/dist/core/project-config.js +11 -2
- package/dist/core/references.d.ts +20 -0
- package/dist/core/references.js +56 -0
- package/dist/core/shared/skill-content-equivalence.js +5 -1
- package/dist/core/shared/skill-generation.js +36 -8
- package/dist/core/shared/tool-detection.js +37 -4
- package/dist/core/specs-apply.js +260 -18
- package/dist/core/store/git.d.ts +33 -0
- package/dist/core/store/git.js +60 -6
- package/dist/core/store/operations.d.ts +1 -1
- package/dist/core/store/operations.js +32 -10
- package/dist/core/store/registry.d.ts +2 -1
- package/dist/core/store/registry.js +5 -1
- package/dist/core/templates/optional-workflow.d.ts +69 -0
- package/dist/core/templates/optional-workflow.js +140 -0
- package/dist/core/templates/workflows/apply-change.js +18 -3
- package/dist/core/templates/workflows/archive-change.js +50 -15
- package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
- package/dist/core/templates/workflows/continue-change.js +14 -2
- package/dist/core/templates/workflows/explore.js +48 -14
- package/dist/core/templates/workflows/ff-change.js +19 -3
- package/dist/core/templates/workflows/new-change.js +15 -3
- package/dist/core/templates/workflows/onboard.js +67 -37
- package/dist/core/templates/workflows/project-root.d.ts +29 -0
- package/dist/core/templates/workflows/project-root.js +38 -0
- package/dist/core/templates/workflows/propose.js +52 -15
- package/dist/core/templates/workflows/sync-specs.js +40 -1
- package/dist/core/templates/workflows/update-change.js +52 -25
- package/dist/core/templates/workflows/verify-change.js +12 -3
- package/dist/core/update.d.ts +34 -0
- package/dist/core/update.js +109 -11
- package/dist/core/validation/task-checkboxes.d.ts +34 -0
- package/dist/core/validation/task-checkboxes.js +181 -0
- package/dist/core/validation/validator.d.ts +53 -1
- package/dist/core/validation/validator.js +154 -40
- package/dist/core/version-check.d.ts +14 -7
- package/dist/core/version-check.js +63 -17
- package/dist/core/worksets.js +6 -4
- package/dist/prompts/searchable-multi-select.d.ts +7 -0
- package/dist/prompts/searchable-multi-select.js +35 -6
- package/dist/telemetry/config.js +14 -2
- package/dist/telemetry/index.d.ts +4 -3
- package/dist/telemetry/index.js +22 -10
- package/dist/telemetry/opt-out.d.ts +17 -0
- package/dist/telemetry/opt-out.js +27 -0
- package/dist/utils/nested-change.d.ts +35 -0
- package/dist/utils/nested-change.js +186 -0
- package/dist/utils/spec-discovery.d.ts +20 -0
- package/dist/utils/spec-discovery.js +59 -0
- package/dist/utils/task-progress.d.ts +1 -1
- package/dist/utils/task-progress.js +38 -6
- package/package.json +4 -4
- package/schemas/spec-driven/schema.yaml +24 -5
- package/schemas/spec-driven/templates/design.md +2 -0
- package/schemas/spec-driven/templates/proposal.md +2 -0
- package/schemas/spec-driven/templates/spec.md +2 -0
- package/schemas/spec-driven/templates/tasks.md +2 -0
|
@@ -22,8 +22,21 @@ const HEADER_LINE = /^#{1,6}\s/;
|
|
|
22
22
|
* as a scenario, so the delta counter must too (parity). The delta/loss path
|
|
23
23
|
* reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
|
|
24
24
|
* keep both paths on it rather than reintroducing a separate `Scenario:` regex.
|
|
25
|
+
* A header alone is not yet a scenario on either path: see hasScenarioBody.
|
|
25
26
|
*/
|
|
26
27
|
export const SCENARIO_HEADER = /^####\s+/;
|
|
28
|
+
/** A header at scenario level or above (`#` to `####`): where a scenario body ends. */
|
|
29
|
+
const SCENARIO_BODY_END = /^#{1,4}\s/;
|
|
30
|
+
/**
|
|
31
|
+
* Whether a scenario's body has content. The spec path
|
|
32
|
+
* (`MarkdownParser.parseScenarios`) drops a scenario whose body is empty, so
|
|
33
|
+
* the delta counter must too (parity): otherwise `validate` accepts a
|
|
34
|
+
* requirement whose only scenario is a bare header, and archive rejects it when
|
|
35
|
+
* it validates the rebuilt spec.
|
|
36
|
+
*/
|
|
37
|
+
export function hasScenarioBody(body) {
|
|
38
|
+
return body.trim().length > 0;
|
|
39
|
+
}
|
|
27
40
|
/**
|
|
28
41
|
* The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
|
|
29
42
|
* as whole words so the change-delta reader and the schema-based reader accept
|
|
@@ -82,18 +95,32 @@ export function extractRequirementText(headerTitle, bodyLines) {
|
|
|
82
95
|
}
|
|
83
96
|
/**
|
|
84
97
|
* Count the real scenarios in a requirement block: `#### ` headers on non-fenced
|
|
85
|
-
* lines. A `#### Scenario:` that lives inside a fenced
|
|
86
|
-
* scenario and is not counted.
|
|
98
|
+
* lines whose body has content. A `#### Scenario:` that lives inside a fenced
|
|
99
|
+
* example is not a real scenario and is not counted.
|
|
87
100
|
*/
|
|
88
101
|
export function countScenarios(bodyLines) {
|
|
102
|
+
return readScenarioBodies(bodyLines).filter(hasScenarioBody).length;
|
|
103
|
+
}
|
|
104
|
+
/** Count the `#### ` headers in a requirement block that have no body under them. */
|
|
105
|
+
export function countEmptyScenarios(bodyLines) {
|
|
106
|
+
return readScenarioBodies(bodyLines).filter((body) => !hasScenarioBody(body)).length;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The body of each scenario in a requirement block. A body runs to the next
|
|
110
|
+
* non-fenced header of level 4 or above, the boundary the spec path uses, so a
|
|
111
|
+
* fenced block or a deeper `#####` header is part of it.
|
|
112
|
+
*/
|
|
113
|
+
function readScenarioBodies(bodyLines) {
|
|
89
114
|
const mask = buildCodeFenceMask(bodyLines);
|
|
90
|
-
|
|
115
|
+
const bodies = [];
|
|
91
116
|
for (let i = 0; i < bodyLines.length; i++) {
|
|
92
|
-
if (mask[i])
|
|
117
|
+
if (mask[i] || !SCENARIO_HEADER.test(bodyLines[i]))
|
|
93
118
|
continue;
|
|
94
|
-
|
|
95
|
-
|
|
119
|
+
let end = i + 1;
|
|
120
|
+
while (end < bodyLines.length && (mask[end] || !SCENARIO_BODY_END.test(bodyLines[end])))
|
|
121
|
+
end++;
|
|
122
|
+
bodies.push(bodyLines.slice(i + 1, end).join('\n'));
|
|
96
123
|
}
|
|
97
|
-
return
|
|
124
|
+
return bodies;
|
|
98
125
|
}
|
|
99
126
|
//# sourceMappingURL=requirement-text.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildCodeFenceMask } from './code-fence.js';
|
|
2
|
+
import { normalizeRequirementName } from './requirement-blocks.js';
|
|
2
3
|
const REQUIREMENTS_SECTION_HEADER = /^##\s+Requirements\s*$/i;
|
|
3
4
|
const TOP_LEVEL_SECTION_HEADER = /^##\s+/;
|
|
4
5
|
const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i;
|
|
@@ -53,7 +54,9 @@ export function findMainSpecStructureIssues(content) {
|
|
|
53
54
|
});
|
|
54
55
|
continue;
|
|
55
56
|
}
|
|
56
|
-
|
|
57
|
+
// The same name every other reader uses, so a closed heading
|
|
58
|
+
// (`### Requirement: Foo ###`) duplicates `### Requirement: Foo`.
|
|
59
|
+
const requirementName = normalizeRequirementName(requirementMatch[1]);
|
|
57
60
|
const previousLine = requirementLines.get(requirementName);
|
|
58
61
|
if (previousLine !== undefined) {
|
|
59
62
|
issues.push({
|
|
@@ -116,7 +116,10 @@ export declare function resolveConfigFilePath(projectRoot: string): string | nul
|
|
|
116
116
|
/** Human rendering of a malformed pointer reason, shared by every surface. */
|
|
117
117
|
export declare function storePointerProblem(reason: 'unparseable' | 'non_string'): string;
|
|
118
118
|
export interface OpenSpecDirClassification {
|
|
119
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* True when openspec/specs or openspec/changes exists as a directory
|
|
121
|
+
* that is not itself a store root.
|
|
122
|
+
*/
|
|
120
123
|
hasPlanningShape: boolean;
|
|
121
124
|
pointer: StorePointerRead;
|
|
122
125
|
}
|
|
@@ -124,6 +127,11 @@ export interface OpenSpecDirClassification {
|
|
|
124
127
|
* One classification for "real root vs config-only pointer dir", shared
|
|
125
128
|
* by root resolution and the init pointer guard so they can never
|
|
126
129
|
* disagree (slice 3.2).
|
|
130
|
+
*
|
|
131
|
+
* A specs/ or changes/ directory carrying store metadata is a store at
|
|
132
|
+
* the recommended `~/openspec/<id>` layout whose id is `specs` or
|
|
133
|
+
* `changes`, not planning content of the directory above it. Counting it
|
|
134
|
+
* would make $HOME the phantom root the qualifying walk exists to prevent.
|
|
127
135
|
*/
|
|
128
136
|
export declare function classifyOpenSpecDir(projectRoot: string): OpenSpecDirClassification;
|
|
129
137
|
//# sourceMappingURL=project-config.d.ts.map
|
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { parse as parseYaml } from 'yaml';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { getStoreMetadataPath } from './store/foundation.js';
|
|
5
6
|
export const OPERATION_IDS = ['apply', 'archive'];
|
|
6
7
|
const OperationConfigSchema = z.object({
|
|
7
8
|
guidance: z.array(z.string()).optional(),
|
|
@@ -455,13 +456,21 @@ export function storePointerProblem(reason) {
|
|
|
455
456
|
* One classification for "real root vs config-only pointer dir", shared
|
|
456
457
|
* by root resolution and the init pointer guard so they can never
|
|
457
458
|
* disagree (slice 3.2).
|
|
459
|
+
*
|
|
460
|
+
* A specs/ or changes/ directory carrying store metadata is a store at
|
|
461
|
+
* the recommended `~/openspec/<id>` layout whose id is `specs` or
|
|
462
|
+
* `changes`, not planning content of the directory above it. Counting it
|
|
463
|
+
* would make $HOME the phantom root the qualifying walk exists to prevent.
|
|
458
464
|
*/
|
|
459
465
|
export function classifyOpenSpecDir(projectRoot) {
|
|
460
466
|
const openspecDir = path.join(projectRoot, 'openspec');
|
|
461
|
-
const hasPlanningShape =
|
|
462
|
-
|
|
467
|
+
const hasPlanningShape = isPlanningDirectorySync(path.join(openspecDir, 'specs')) ||
|
|
468
|
+
isPlanningDirectorySync(path.join(openspecDir, 'changes'));
|
|
463
469
|
return { hasPlanningShape, pointer: readStorePointer(projectRoot) };
|
|
464
470
|
}
|
|
471
|
+
function isPlanningDirectorySync(candidatePath) {
|
|
472
|
+
return isDirectorySync(candidatePath) && !existsSync(getStoreMetadataPath(candidatePath));
|
|
473
|
+
}
|
|
465
474
|
function isDirectorySync(candidatePath) {
|
|
466
475
|
try {
|
|
467
476
|
return statSync(candidatePath).isDirectory();
|
|
@@ -36,6 +36,26 @@ export declare function renderReferencedStoresSection(entries: ReferenceIndexEnt
|
|
|
36
36
|
* let hostile content forge instruction lines (slice 6.1 hardening).
|
|
37
37
|
*/
|
|
38
38
|
export declare function sanitizeInline(value: string, maxLength?: number): string;
|
|
39
|
+
/**
|
|
40
|
+
* Neutralize the envelope's own tags - opening and closing - in repo-supplied
|
|
41
|
+
* text, so it cannot close the block that frames it as data nor forge a new
|
|
42
|
+
* block that carries authority.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately narrow: only this fixed vocabulary is touched. Escaping every
|
|
45
|
+
* angle bracket also works, but it mangles ordinary content for everyone.
|
|
46
|
+
* OpenSpec's own spec-driven schema writes `### Requirement: <name>` and
|
|
47
|
+
* `openspec show "<spec-id>"`; custom templates carry `<details>`; and
|
|
48
|
+
* `context:` routinely holds `R&D`, `pnpm build && pnpm test` or
|
|
49
|
+
* `Result<T, E>`. All of those would reach the agent entity-encoded - a real
|
|
50
|
+
* cost paid by every user, against a threat only these tags can carry.
|
|
51
|
+
*/
|
|
52
|
+
export declare function escapeEnvelopeTags(value: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* Attribute values are ids and directory names, never prose, so escaping every
|
|
55
|
+
* metacharacter here costs nothing and stops a quote from closing the
|
|
56
|
+
* attribute and forging siblings on the tag.
|
|
57
|
+
*/
|
|
58
|
+
export declare function escapeEnvelopeAttribute(value: string): string;
|
|
39
59
|
export interface AssembleReferenceIndexInput {
|
|
40
60
|
references: DeclarationEntry[];
|
|
41
61
|
resolvedRoot: ResolvedOpenSpecRoot;
|
package/dist/core/references.js
CHANGED
|
@@ -194,6 +194,62 @@ export function sanitizeInline(value, maxLength = 300) {
|
|
|
194
194
|
const flattened = value.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
|
|
195
195
|
return flattened.length > maxLength ? `${flattened.slice(0, maxLength)}…` : flattened;
|
|
196
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* The tags the instruction printer uses to frame its blocks. A block ends at
|
|
199
|
+
* its own closing tag and nowhere else, so this is the entire breakout
|
|
200
|
+
* surface: neutralize these and repo-supplied text cannot escape the element
|
|
201
|
+
* that marks it as data.
|
|
202
|
+
*/
|
|
203
|
+
const ENVELOPE_TAGS = [
|
|
204
|
+
'artifact',
|
|
205
|
+
'dependencies',
|
|
206
|
+
'dependency',
|
|
207
|
+
'description',
|
|
208
|
+
'instruction',
|
|
209
|
+
'output',
|
|
210
|
+
'path',
|
|
211
|
+
'project_context',
|
|
212
|
+
'rules',
|
|
213
|
+
'success_criteria',
|
|
214
|
+
'task',
|
|
215
|
+
'template',
|
|
216
|
+
'unlocks',
|
|
217
|
+
'warning',
|
|
218
|
+
];
|
|
219
|
+
// The attribute tail uses `[^<>]` rather than `[^>]` so a run of unterminated
|
|
220
|
+
// `<task ...` openers cannot make each start position scan to end of input,
|
|
221
|
+
// which is how the first version of this escape became quadratic. The separator
|
|
222
|
+
// is `\s`, not a space or tab: XML allows a line break before `>` or an
|
|
223
|
+
// attribute, so a multiline repo value could otherwise split a tag past this.
|
|
224
|
+
const ENVELOPE_TAG = new RegExp(`<(/?)(${ENVELOPE_TAGS.join('|')})(\\s[^<>]*)?>`, 'gi');
|
|
225
|
+
/**
|
|
226
|
+
* Neutralize the envelope's own tags - opening and closing - in repo-supplied
|
|
227
|
+
* text, so it cannot close the block that frames it as data nor forge a new
|
|
228
|
+
* block that carries authority.
|
|
229
|
+
*
|
|
230
|
+
* Deliberately narrow: only this fixed vocabulary is touched. Escaping every
|
|
231
|
+
* angle bracket also works, but it mangles ordinary content for everyone.
|
|
232
|
+
* OpenSpec's own spec-driven schema writes `### Requirement: <name>` and
|
|
233
|
+
* `openspec show "<spec-id>"`; custom templates carry `<details>`; and
|
|
234
|
+
* `context:` routinely holds `R&D`, `pnpm build && pnpm test` or
|
|
235
|
+
* `Result<T, E>`. All of those would reach the agent entity-encoded - a real
|
|
236
|
+
* cost paid by every user, against a threat only these tags can carry.
|
|
237
|
+
*/
|
|
238
|
+
export function escapeEnvelopeTags(value) {
|
|
239
|
+
return value.replace(ENVELOPE_TAG, (_match, slash, tag, attrs) => `<${slash}${tag}${attrs ?? ''}>`);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Attribute values are ids and directory names, never prose, so escaping every
|
|
243
|
+
* metacharacter here costs nothing and stops a quote from closing the
|
|
244
|
+
* attribute and forging siblings on the tag.
|
|
245
|
+
*/
|
|
246
|
+
export function escapeEnvelopeAttribute(value) {
|
|
247
|
+
return value
|
|
248
|
+
.replace(/&/g, '&')
|
|
249
|
+
.replace(/</g, '<')
|
|
250
|
+
.replace(/>/g, '>')
|
|
251
|
+
.replace(/"/g, '"');
|
|
252
|
+
}
|
|
197
253
|
function renderEntryLines(entry) {
|
|
198
254
|
const lines = [];
|
|
199
255
|
if (entry.root !== undefined) {
|
|
@@ -11,7 +11,11 @@ function normalizeGeneratedSkill(content) {
|
|
|
11
11
|
const frontmatter = normalized.match(/^---\n[\s\S]*?\n---(?:\n|$)/)?.[0];
|
|
12
12
|
if (!frontmatter)
|
|
13
13
|
return normalized;
|
|
14
|
-
|
|
14
|
+
// `[ \t]` rather than `\s` so a leading/trailing whitespace run can never
|
|
15
|
+
// cross a newline: an `m`-anchored `\s*` re-scans from every line start,
|
|
16
|
+
// which is quadratic on a whitespace-heavy frontmatter. YAML indentation is
|
|
17
|
+
// spaces and tabs only, so matching is unchanged.
|
|
18
|
+
const versionLine = /^([ \t]*generatedBy:[ \t]*)(?:"([^"\n]+)"|'([^'\n]+)'|([^\s"'#]+))[ \t]*$/m;
|
|
15
19
|
const normalizedFrontmatter = frontmatter.replace(versionLine, (line, prefix, doubleQuoted, singleQuoted, bare) => {
|
|
16
20
|
const version = doubleQuoted ?? singleQuoted ?? bare;
|
|
17
21
|
return version && GENERATED_VERSION.test(version)
|
|
@@ -4,7 +4,24 @@
|
|
|
4
4
|
* Shared utilities for generating skill and command files.
|
|
5
5
|
*/
|
|
6
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 { assertWorkflowConditionalsResolved, resolveOptionalWorkflows, } from '../templates/optional-workflow.js';
|
|
8
|
+
import { ALL_WORKFLOWS } from '../profiles.js';
|
|
7
9
|
import { OPENSPEC_CLI_ALLOWED_TOOLS } from './allowed-tools.js';
|
|
10
|
+
/**
|
|
11
|
+
* The workflow set a template body is rendered against.
|
|
12
|
+
*
|
|
13
|
+
* `workflowFilter` is both the list of workflows to install and the set a
|
|
14
|
+
* template may refer to, so resolving optional-workflow conditionals here —
|
|
15
|
+
* the one place every generation path (init, update, migration, the skills.sh
|
|
16
|
+
* distribution) already funnels through — keeps a reference to an uninstalled
|
|
17
|
+
* workflow out of every generated file (#1734, umbrella #919).
|
|
18
|
+
*
|
|
19
|
+
* With no filter, every workflow is installed (that is what an unfiltered call
|
|
20
|
+
* means), so the installed branch is kept.
|
|
21
|
+
*/
|
|
22
|
+
function resolveInstalledWorkflows(workflowFilter) {
|
|
23
|
+
return new Set(workflowFilter ?? ALL_WORKFLOWS);
|
|
24
|
+
}
|
|
8
25
|
/**
|
|
9
26
|
* Gets skill templates with their directory names, optionally filtered by workflow IDs.
|
|
10
27
|
*
|
|
@@ -25,10 +42,15 @@ export function getSkillTemplates(workflowFilter) {
|
|
|
25
42
|
{ template: getOnboardSkillTemplate(), dirName: 'openspec-onboard', workflowId: 'onboard' },
|
|
26
43
|
{ template: getOpsxProposeSkillTemplate(), dirName: 'openspec-propose', workflowId: 'propose' },
|
|
27
44
|
];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
45
|
+
const installed = resolveInstalledWorkflows(workflowFilter);
|
|
46
|
+
const selected = workflowFilter ? all.filter(entry => installed.has(entry.workflowId)) : all;
|
|
47
|
+
return selected.map(entry => ({
|
|
48
|
+
...entry,
|
|
49
|
+
template: {
|
|
50
|
+
...entry.template,
|
|
51
|
+
instructions: resolveOptionalWorkflows(entry.template.instructions, installed),
|
|
52
|
+
},
|
|
53
|
+
}));
|
|
32
54
|
}
|
|
33
55
|
/**
|
|
34
56
|
* Gets command templates with their IDs, optionally filtered by workflow IDs.
|
|
@@ -50,10 +72,15 @@ export function getCommandTemplates(workflowFilter) {
|
|
|
50
72
|
{ template: getOpsxOnboardCommandTemplate(), id: 'onboard' },
|
|
51
73
|
{ template: getOpsxProposeCommandTemplate(), id: 'propose' },
|
|
52
74
|
];
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
75
|
+
const installed = resolveInstalledWorkflows(workflowFilter);
|
|
76
|
+
const selected = workflowFilter ? all.filter(entry => installed.has(entry.id)) : all;
|
|
77
|
+
return selected.map(entry => ({
|
|
78
|
+
...entry,
|
|
79
|
+
template: {
|
|
80
|
+
...entry.template,
|
|
81
|
+
content: resolveOptionalWorkflows(entry.template.content, installed),
|
|
82
|
+
},
|
|
83
|
+
}));
|
|
57
84
|
}
|
|
58
85
|
/**
|
|
59
86
|
* Converts command templates to CommandContent array, optionally filtered by workflow IDs.
|
|
@@ -82,6 +109,7 @@ export function generateSkillContent(template, generatedByVersion, transformInst
|
|
|
82
109
|
const instructions = transformInstructions
|
|
83
110
|
? transformInstructions(template.instructions)
|
|
84
111
|
: template.instructions;
|
|
112
|
+
assertWorkflowConditionalsResolved(instructions, `Skill '${template.name}' was generated without resolving its optional-workflow blocks`);
|
|
85
113
|
return `---
|
|
86
114
|
name: ${template.name}
|
|
87
115
|
description: ${template.description}
|
|
@@ -191,9 +191,15 @@ export function extractGeneratedByVersion(skillFilePath) {
|
|
|
191
191
|
// version: "1.0"
|
|
192
192
|
// generatedBy: "0.23.0"
|
|
193
193
|
// ---
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
// Scanned per line, and with `[ \t]` rather than `\s`, so the leading
|
|
195
|
+
// whitespace run can never cross a newline. A single `m`-anchored `\s*`
|
|
196
|
+
// over the whole file re-scans it from every line start, which is
|
|
197
|
+
// quadratic on a whitespace-heavy SKILL.md.
|
|
198
|
+
for (const line of content.split(/\r\n?|\n/)) {
|
|
199
|
+
const generatedByMatch = line.match(/^[ \t]*generatedBy:[ \t]*["']?([^"'\n]+)["']?[ \t]*$/);
|
|
200
|
+
if (generatedByMatch && generatedByMatch[1]) {
|
|
201
|
+
return generatedByMatch[1].trim();
|
|
202
|
+
}
|
|
197
203
|
}
|
|
198
204
|
return null;
|
|
199
205
|
}
|
|
@@ -253,7 +259,34 @@ export function getToolVersionStatus(projectRoot, toolId, currentVersion, option
|
|
|
253
259
|
generatedByVersion = currentVersion;
|
|
254
260
|
}
|
|
255
261
|
}
|
|
256
|
-
|
|
262
|
+
// 3. A version marker in a skill file only proves the SKILL files came from
|
|
263
|
+
// this CLI. It says nothing about the command files written beside them,
|
|
264
|
+
// which a user may have hand-edited or a partial write may have truncated.
|
|
265
|
+
// Without this, `update` answered "all tools up to date" while a damaged
|
|
266
|
+
// command file sat on disk, repairable only by knowing to pass --force.
|
|
267
|
+
// The content comparison already exists; it was simply never consulted
|
|
268
|
+
// once a skill file supplied a version.
|
|
269
|
+
//
|
|
270
|
+
// Scoped to tools that have BOTH, so the commands-only path above keeps
|
|
271
|
+
// its exact behaviour, and skipped when the delivery mode generates no
|
|
272
|
+
// commands for this tool - there would be nothing to compare against, and
|
|
273
|
+
// `areCommandFilesUpToDate` reports an empty command set as "not current".
|
|
274
|
+
let commandsDrifted = false;
|
|
275
|
+
if (skillConfigured && commandConfigured) {
|
|
276
|
+
let generatesCommands = true;
|
|
277
|
+
try {
|
|
278
|
+
generatesCommands = shouldGenerateCommandsForTool(toolId, getGlobalConfig().delivery ?? 'both');
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
generatesCommands = true;
|
|
282
|
+
}
|
|
283
|
+
commandsDrifted =
|
|
284
|
+
generatesCommands && !areCommandFilesUpToDate(projectRoot, toolId, options);
|
|
285
|
+
}
|
|
286
|
+
const needsUpdate = configured &&
|
|
287
|
+
(generatedByVersion === null ||
|
|
288
|
+
generatedByVersion !== currentVersion ||
|
|
289
|
+
commandsDrifted);
|
|
257
290
|
return {
|
|
258
291
|
toolId,
|
|
259
292
|
toolName: tool.name,
|