@fission-ai/openspec 1.13.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.
Files changed (102) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/change.js +12 -1
  3. package/dist/commands/config.d.ts +9 -0
  4. package/dist/commands/config.js +161 -22
  5. package/dist/commands/feedback.js +6 -4
  6. package/dist/commands/schema.js +30 -9
  7. package/dist/commands/store.js +5 -2
  8. package/dist/commands/validate.d.ts +7 -0
  9. package/dist/commands/validate.js +63 -1
  10. package/dist/commands/workflow/instructions.js +33 -14
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.js +8 -0
  13. package/dist/commands/workflow/status.d.ts +5 -1
  14. package/dist/commands/workflow/status.js +42 -5
  15. package/dist/core/archive.js +17 -1
  16. package/dist/core/artifact-graph/schema.d.ts +14 -0
  17. package/dist/core/artifact-graph/schema.js +52 -0
  18. package/dist/core/artifact-graph/types.js +12 -1
  19. package/dist/core/change-status-policy.d.ts +14 -0
  20. package/dist/core/change-status-policy.js +14 -6
  21. package/dist/core/command-generation/generator.js +2 -0
  22. package/dist/core/completion-tip.js +7 -7
  23. package/dist/core/completions/installers/bash-installer.js +15 -7
  24. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  25. package/dist/core/completions/installers/shell-quote.js +13 -0
  26. package/dist/core/completions/installers/zsh-installer.js +3 -2
  27. package/dist/core/config-schema.d.ts +1 -1
  28. package/dist/core/config.d.ts +16 -0
  29. package/dist/core/config.js +27 -1
  30. package/dist/core/global-config.d.ts +30 -2
  31. package/dist/core/global-config.js +57 -3
  32. package/dist/core/init.js +13 -3
  33. package/dist/core/legacy-cleanup.d.ts +4 -0
  34. package/dist/core/legacy-cleanup.js +180 -12
  35. package/dist/core/list.js +55 -11
  36. package/dist/core/migration.js +6 -1
  37. package/dist/core/parsers/change-parser.d.ts +12 -1
  38. package/dist/core/parsers/change-parser.js +85 -87
  39. package/dist/core/parsers/markdown-parser.js +4 -3
  40. package/dist/core/parsers/requirement-blocks.d.ts +44 -3
  41. package/dist/core/parsers/requirement-blocks.js +90 -15
  42. package/dist/core/parsers/requirement-text.d.ts +13 -2
  43. package/dist/core/parsers/requirement-text.js +34 -7
  44. package/dist/core/parsers/spec-structure.js +4 -1
  45. package/dist/core/project-config.d.ts +9 -1
  46. package/dist/core/project-config.js +11 -2
  47. package/dist/core/references.d.ts +20 -0
  48. package/dist/core/references.js +56 -0
  49. package/dist/core/shared/skill-content-equivalence.js +5 -1
  50. package/dist/core/shared/skill-generation.js +36 -8
  51. package/dist/core/shared/tool-detection.js +9 -3
  52. package/dist/core/specs-apply.js +82 -11
  53. package/dist/core/store/git.d.ts +33 -0
  54. package/dist/core/store/git.js +60 -6
  55. package/dist/core/store/operations.d.ts +1 -1
  56. package/dist/core/store/operations.js +32 -10
  57. package/dist/core/store/registry.d.ts +2 -1
  58. package/dist/core/store/registry.js +5 -1
  59. package/dist/core/templates/optional-workflow.d.ts +69 -0
  60. package/dist/core/templates/optional-workflow.js +140 -0
  61. package/dist/core/templates/workflows/apply-change.js +18 -3
  62. package/dist/core/templates/workflows/archive-change.js +50 -15
  63. package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
  64. package/dist/core/templates/workflows/continue-change.js +14 -2
  65. package/dist/core/templates/workflows/explore.js +32 -14
  66. package/dist/core/templates/workflows/ff-change.js +19 -3
  67. package/dist/core/templates/workflows/new-change.js +15 -3
  68. package/dist/core/templates/workflows/onboard.js +67 -37
  69. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  70. package/dist/core/templates/workflows/project-root.js +38 -0
  71. package/dist/core/templates/workflows/propose.js +22 -5
  72. package/dist/core/templates/workflows/sync-specs.js +40 -1
  73. package/dist/core/templates/workflows/update-change.js +52 -25
  74. package/dist/core/templates/workflows/verify-change.js +12 -3
  75. package/dist/core/update.d.ts +12 -0
  76. package/dist/core/update.js +58 -3
  77. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  78. package/dist/core/validation/task-checkboxes.js +181 -0
  79. package/dist/core/validation/validator.d.ts +53 -1
  80. package/dist/core/validation/validator.js +154 -40
  81. package/dist/core/version-check.d.ts +14 -7
  82. package/dist/core/version-check.js +63 -17
  83. package/dist/core/worksets.js +6 -4
  84. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  85. package/dist/prompts/searchable-multi-select.js +35 -6
  86. package/dist/telemetry/config.js +14 -2
  87. package/dist/telemetry/index.d.ts +4 -3
  88. package/dist/telemetry/index.js +22 -10
  89. package/dist/telemetry/opt-out.d.ts +17 -0
  90. package/dist/telemetry/opt-out.js +27 -0
  91. package/dist/utils/nested-change.d.ts +35 -0
  92. package/dist/utils/nested-change.js +186 -0
  93. package/dist/utils/spec-discovery.d.ts +20 -0
  94. package/dist/utils/spec-discovery.js +59 -0
  95. package/dist/utils/task-progress.d.ts +1 -1
  96. package/dist/utils/task-progress.js +38 -6
  97. package/package.json +4 -4
  98. package/schemas/spec-driven/schema.yaml +10 -3
  99. package/schemas/spec-driven/templates/design.md +2 -0
  100. package/schemas/spec-driven/templates/proposal.md +2 -0
  101. package/schemas/spec-driven/templates/spec.md +2 -0
  102. package/schemas/spec-driven/templates/tasks.md +2 -0
@@ -15,8 +15,17 @@ export { buildCodeFenceMask } from './code-fence.js';
15
15
  * as a scenario, so the delta counter must too (parity). The delta/loss path
16
16
  * reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
17
17
  * keep both paths on it rather than reintroducing a separate `Scenario:` regex.
18
+ * A header alone is not yet a scenario on either path: see hasScenarioBody.
18
19
  */
19
20
  export declare const SCENARIO_HEADER: RegExp;
21
+ /**
22
+ * Whether a scenario's body has content. The spec path
23
+ * (`MarkdownParser.parseScenarios`) drops a scenario whose body is empty, so
24
+ * the delta counter must too (parity): otherwise `validate` accepts a
25
+ * requirement whose only scenario is a bare header, and archive rejects it when
26
+ * it validates the rebuilt spec.
27
+ */
28
+ export declare function hasScenarioBody(body: string): boolean;
20
29
  /**
21
30
  * The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
22
31
  * as whole words so the change-delta reader and the schema-based reader accept
@@ -49,8 +58,10 @@ export declare function extractRequirementBody(bodyLines: string[]): string;
49
58
  export declare function extractRequirementText(headerTitle: string, bodyLines: string[]): string;
50
59
  /**
51
60
  * Count the real scenarios in a requirement block: `#### ` headers on non-fenced
52
- * lines. A `#### Scenario:` that lives inside a fenced example is not a real
53
- * scenario and is not counted.
61
+ * lines whose body has content. A `#### Scenario:` that lives inside a fenced
62
+ * example is not a real scenario and is not counted.
54
63
  */
55
64
  export declare function countScenarios(bodyLines: string[]): number;
65
+ /** Count the `#### ` headers in a requirement block that have no body under them. */
66
+ export declare function countEmptyScenarios(bodyLines: string[]): number;
56
67
  //# sourceMappingURL=requirement-text.d.ts.map
@@ -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 example is not a real
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
- let count = 0;
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
- if (SCENARIO_HEADER.test(bodyLines[i]))
95
- count++;
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 count;
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
- const requirementName = requirementMatch[1].trim();
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
- /** True when openspec/specs or openspec/changes exists as a directory. */
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 = isDirectorySync(path.join(openspecDir, 'specs')) ||
462
- isDirectorySync(path.join(openspecDir, 'changes'));
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;
@@ -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) => `&lt;${slash}${tag}${attrs ?? ''}&gt;`);
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, '&amp;')
249
+ .replace(/</g, '&lt;')
250
+ .replace(/>/g, '&gt;')
251
+ .replace(/"/g, '&quot;');
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
- const versionLine = /^(\s*generatedBy:\s*)(?:"([^"\n]+)"|'([^'\n]+)'|([^\s"'#]+))\s*$/m;
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
- if (!workflowFilter)
29
- return all;
30
- const filterSet = new Set(workflowFilter);
31
- return all.filter(entry => filterSet.has(entry.workflowId));
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
- if (!workflowFilter)
54
- return all;
55
- const filterSet = new Set(workflowFilter);
56
- return all.filter(entry => filterSet.has(entry.id));
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
- const generatedByMatch = content.match(/^\s*generatedBy:\s*["']?([^"'\n]+)["']?\s*$/m);
195
- if (generatedByMatch && generatedByMatch[1]) {
196
- return generatedByMatch[1].trim();
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
  }
@@ -22,7 +22,7 @@ function isLexicallyWithin(allowedDirectory, targetPath) {
22
22
  !relative.startsWith(`..${path.sep}`) &&
23
23
  !path.isAbsolute(relative)));
24
24
  }
25
- function resolveTrustedSpecPath(specsRoot, specPath) {
25
+ function resolveTrustedSpecPath(specsRoot, specPath, projectRoot) {
26
26
  if (!isLexicallyWithin(specsRoot, specPath)) {
27
27
  throw new Error(`Path is outside the allowed directory: ${specPath}`);
28
28
  }
@@ -42,6 +42,14 @@ function resolveTrustedSpecPath(specsRoot, specPath) {
42
42
  // Freeze their canonical location as the trust root so later swaps are
43
43
  // rejected while a nested spec.md link still cannot escape.
44
44
  const root = FileSystemUtils.canonicalizeExistingPath(path.dirname(specPath));
45
+ // An external capability link is deliberate and supported (see
46
+ // assertDiscoveredSpecPath), so it is not refused here. What was wrong is
47
+ // that the write was silent: the CLI reported the in-project path while
48
+ // writing somewhere else entirely, so a link swapped underneath a repo
49
+ // left nothing on screen to notice. Name the real destination instead.
50
+ if (projectRoot && !isLexicallyWithin(FileSystemUtils.canonicalizeExistingPath(projectRoot), root)) {
51
+ process.emitWarning(`Capability '${path.basename(path.dirname(specPath))}' links outside the project; writing to ${root}`, 'OpenSpecExternalSpecWrite');
52
+ }
45
53
  const file = path.join(root, path.basename(specPath));
46
54
  FileSystemUtils.assertPathWithin(root, file);
47
55
  return { root, file };
@@ -69,7 +77,10 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
69
77
  for (const { id, specFile } of discovered) {
70
78
  const targetFile = path.join(mainSpecsDir, ...id.split('/'), 'spec.md');
71
79
  const source = resolveTrustedSpecPath(changeSpecsDir, specFile);
72
- const target = resolveTrustedSpecPath(mainSpecsDir, targetFile);
80
+ // Main specs always live at `<project root>/openspec/specs`, so the
81
+ // project root is the grandparent - a linked capability directory may not
82
+ // leave it.
83
+ const target = resolveTrustedSpecPath(mainSpecsDir, targetFile, path.dirname(path.dirname(mainSpecsDir)));
73
84
  // Check if target exists
74
85
  let exists = false;
75
86
  try {
@@ -110,6 +121,29 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
110
121
  // Parse deltas from the change spec file
111
122
  const plan = parseDeltaSpec(changeContent);
112
123
  const specName = update.id;
124
+ // A FROM:/TO: line that never formed a pair means the RENAMED section does not
125
+ // say what the author meant. Refuse rather than apply the pairing the reader
126
+ // happened to form: with interleaved lines that pairing renames a requirement
127
+ // the delta never named, under a name written for a different one.
128
+ if (plan.unpairedRenames.length > 0) {
129
+ const first = plan.unpairedRenames[0];
130
+ const missing = first.side === 'FROM' ? 'TO' : 'FROM';
131
+ throw new Error(`${specName} validation failed - RENAMED entry on line ${first.line} has no matching ${missing}: ` +
132
+ `for header "### Requirement: ${first.name}". ` +
133
+ `Write each rename as a FROM: line followed immediately by its TO: line.`);
134
+ }
135
+ // A well-formed requirement written outside every delta section is not
136
+ // applied. Say so here as well as in validate: archive is the last point at
137
+ // which the author can still notice, and the block reads exactly like one
138
+ // that would have applied.
139
+ for (const orphan of plan.orphanedRequirements) {
140
+ const where = orphan.section
141
+ ? `under "## ${orphan.section}"`
142
+ : 'above the first "## " section';
143
+ warn(`${specName} - requirement "${orphan.name}" (line ${orphan.line}) is ${where}, ` +
144
+ `which is not a delta section, so it was not applied. ` +
145
+ `Move it under ADDED/MODIFIED/REMOVED/RENAMED Requirements.`);
146
+ }
113
147
  // Pre-validate duplicates within sections
114
148
  const addedNames = new Set();
115
149
  for (const add of plan.added) {
@@ -287,6 +321,13 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
287
321
  if (nameToBlock.has(to)) {
288
322
  throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists`);
289
323
  }
324
+ // A target that differs from another requirement only in case or interior
325
+ // whitespace would leave two copies of one requirement. The source itself
326
+ // is exempt, so a case-only rename of a requirement stays allowed.
327
+ const targetNearMiss = [...nameToBlock.keys()].find((k) => k !== from && foldRequirementName(k) === foldRequirementName(to));
328
+ if (targetNearMiss !== undefined) {
329
+ throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - "### Requirement: ${nameToBlock.get(targetNearMiss).name}" already exists and differs only in case or spacing; choose a distinct name`);
330
+ }
290
331
  const block = nameToBlock.get(from);
291
332
  const newHeader = `### Requirement: ${to}`;
292
333
  const rawLines = block.raw.split('\n');
@@ -369,6 +410,15 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
369
410
  }
370
411
  throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`);
371
412
  }
413
+ // A name that differs from an existing requirement only in case or
414
+ // interior whitespace is that requirement written again: adding it would
415
+ // leave two contradicting copies in the spec. Like the exact check above,
416
+ // this compares against the spec as it stands after the earlier operations,
417
+ // so a variant of a requirement this delta removed or renamed away is fine.
418
+ const nearMiss = [...nameToBlock.keys()].find((k) => foldRequirementName(k) === foldRequirementName(key));
419
+ if (nearMiss !== undefined) {
420
+ throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - "### Requirement: ${nameToBlock.get(nearMiss).name}" already exists and differs only in case or spacing; use MODIFIED with that exact header to change it, or choose a distinct name`);
421
+ }
372
422
  nameToBlock.set(key, add);
373
423
  addedApplied++;
374
424
  }
@@ -1011,15 +1061,36 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
1011
1061
  /** Blank out `<!-- ... -->` spans, preserving line count so indices stay aligned. */
1012
1062
  function maskHtmlComments(content) {
1013
1063
  const blank = (text) => text.replace(/[^\n]/g, ' ');
1014
- // `--!>` is a comment terminator as well as `-->`.
1015
- const masked = content.replace(/<!--[\s\S]*?--!?>/g, blank);
1016
- // A comment that is never closed runs to end of file, so everything after it
1017
- // is commented out too. Without this an unterminated `<!--` above a
1018
- // `## Purpose` left the commented-out header looking real (#1413).
1019
- const unterminated = masked.indexOf('<!--');
1020
- if (unterminated === -1)
1021
- return masked;
1022
- return masked.slice(0, unterminated) + blank(masked.slice(unterminated));
1064
+ // Linear scan: every character is visited once. A `/<!--[\s\S]*?--!?>/g`
1065
+ // replace re-scans to end of file from every `<!--`, which is quadratic on a
1066
+ // spec dense in comment openers.
1067
+ let out = '';
1068
+ let index = 0;
1069
+ for (;;) {
1070
+ const open = content.indexOf('<!--', index);
1071
+ if (open === -1)
1072
+ return out + content.slice(index);
1073
+ out += content.slice(index, open);
1074
+ // `--!>` is a comment terminator as well as `-->`.
1075
+ let close = -1;
1076
+ for (let i = open + 4; i < content.length; i++) {
1077
+ if (content.startsWith('-->', i)) {
1078
+ close = i + 3;
1079
+ break;
1080
+ }
1081
+ if (content.startsWith('--!>', i)) {
1082
+ close = i + 4;
1083
+ break;
1084
+ }
1085
+ }
1086
+ // A comment that is never closed runs to end of file, so everything after
1087
+ // it is commented out too. Without this an unterminated `<!--` above a
1088
+ // `## Purpose` left the commented-out header looking real (#1413).
1089
+ if (close === -1)
1090
+ return out + blank(content.slice(open));
1091
+ out += blank(content.slice(open, close));
1092
+ index = close;
1093
+ }
1023
1094
  }
1024
1095
  /**
1025
1096
  * Read the body of a `## Purpose` section, ignoring markdown that only appears
@@ -1,3 +1,29 @@
1
+ /**
2
+ * Bounds every read-only git probe. Without a timeout a wedged network mount, an
3
+ * fsmonitor daemon, or a credential/GPG prompt hangs the CLI forever; without a
4
+ * raised maxBuffer a very large dirty tree makes `git status --porcelain` throw
5
+ * ENOBUFS, which the probes below would otherwise report as "no git facts".
6
+ * A probe writes nothing, so a hard kill is safe.
7
+ */
8
+ export declare const GIT_EXEC_OPTIONS: {
9
+ readonly encoding: "utf8";
10
+ readonly timeout: 15000;
11
+ readonly killSignal: "SIGKILL";
12
+ readonly maxBuffer: number;
13
+ };
14
+ /**
15
+ * Writes get their own bounds, and deliberately NOT SIGKILL: git traps SIGTERM
16
+ * to remove `.git/index.lock` on its way out, and a signal it cannot catch
17
+ * leaves that lock behind - every later git command in the user's store then
18
+ * fails with "Another git process seems to be running", including the
19
+ * best-effort unstage below. The timeout is also far longer, because a signed
20
+ * commit can legitimately sit waiting on pinentry or a hardware key.
21
+ */
22
+ export declare const GIT_WRITE_EXEC_OPTIONS: {
23
+ readonly encoding: "utf8";
24
+ readonly timeout: 120000;
25
+ readonly maxBuffer: number;
26
+ };
1
27
  export declare function isGitRepositoryAtRoot(storeRoot: string): Promise<boolean>;
2
28
  export declare function initGitRepository(storeRoot: string): Promise<boolean>;
3
29
  /**
@@ -11,6 +37,13 @@ export declare function assertGitCommitIdentity(probeCwd: string): Promise<void>
11
37
  * Pathspecs may be files or directories.
12
38
  */
13
39
  export declare function commitStoreFiles(storeRoot: string, id: string, pathspecs: string[]): Promise<boolean>;
40
+ /**
41
+ * A probe that hit a resource limit rather than an ordinary Git answer: the
42
+ * command was killed by the timeout above, or its output exceeded maxBuffer.
43
+ * Both produce the same `null` as "not a repository", so without this the CLI
44
+ * would quietly stop reporting facts it is capable of reporting.
45
+ */
46
+ export declare function isProbeResourceFailure(error: unknown): boolean;
14
47
  export declare function gitHasCommits(storeRoot: string): Promise<boolean | null>;
15
48
  export declare function gitHasUncommittedChanges(storeRoot: string): Promise<boolean | null>;
16
49
  export declare function gitHasRemote(storeRoot: string): Promise<boolean | null>;