@fission-ai/openspec 1.13.0 → 1.13.2

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 (113) hide show
  1. package/README.md +16 -4
  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 +69 -22
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.d.ts +5 -0
  13. package/dist/commands/workflow/shared.js +8 -0
  14. package/dist/commands/workflow/status.d.ts +5 -1
  15. package/dist/commands/workflow/status.js +42 -5
  16. package/dist/core/archive.js +260 -77
  17. package/dist/core/artifact-graph/outputs.d.ts +1 -1
  18. package/dist/core/artifact-graph/outputs.js +38 -8
  19. package/dist/core/artifact-graph/schema.d.ts +14 -0
  20. package/dist/core/artifact-graph/schema.js +52 -0
  21. package/dist/core/artifact-graph/types.js +12 -1
  22. package/dist/core/change-status-policy.d.ts +14 -0
  23. package/dist/core/change-status-policy.js +14 -6
  24. package/dist/core/command-generation/adapters/continue.js +4 -0
  25. package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
  26. package/dist/core/command-generation/adapters/kilocode.js +2 -2
  27. package/dist/core/command-generation/generator.js +2 -0
  28. package/dist/core/completion-tip.js +7 -7
  29. package/dist/core/completions/installers/bash-installer.js +15 -7
  30. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  31. package/dist/core/completions/installers/shell-quote.js +13 -0
  32. package/dist/core/completions/installers/zsh-installer.js +3 -2
  33. package/dist/core/config-schema.d.ts +1 -1
  34. package/dist/core/config.d.ts +16 -0
  35. package/dist/core/config.js +27 -1
  36. package/dist/core/global-config.d.ts +30 -2
  37. package/dist/core/global-config.js +57 -3
  38. package/dist/core/init.js +22 -6
  39. package/dist/core/legacy-cleanup.d.ts +5 -1
  40. package/dist/core/legacy-cleanup.js +207 -14
  41. package/dist/core/list.js +55 -11
  42. package/dist/core/migration.js +6 -1
  43. package/dist/core/parsers/change-parser.d.ts +12 -1
  44. package/dist/core/parsers/change-parser.js +85 -87
  45. package/dist/core/parsers/markdown-parser.js +4 -3
  46. package/dist/core/parsers/requirement-blocks.d.ts +78 -6
  47. package/dist/core/parsers/requirement-blocks.js +155 -38
  48. package/dist/core/parsers/requirement-text.d.ts +13 -2
  49. package/dist/core/parsers/requirement-text.js +34 -7
  50. package/dist/core/parsers/spec-structure.js +4 -1
  51. package/dist/core/project-config.d.ts +9 -1
  52. package/dist/core/project-config.js +11 -2
  53. package/dist/core/references.d.ts +20 -0
  54. package/dist/core/references.js +56 -0
  55. package/dist/core/shared/skill-content-equivalence.js +5 -1
  56. package/dist/core/shared/skill-generation.js +36 -8
  57. package/dist/core/shared/tool-detection.js +9 -3
  58. package/dist/core/specs-apply.d.ts +20 -0
  59. package/dist/core/specs-apply.js +101 -17
  60. package/dist/core/store/git.d.ts +33 -0
  61. package/dist/core/store/git.js +60 -6
  62. package/dist/core/store/operations.d.ts +1 -1
  63. package/dist/core/store/operations.js +32 -10
  64. package/dist/core/store/registry.d.ts +2 -1
  65. package/dist/core/store/registry.js +5 -1
  66. package/dist/core/templates/optional-workflow.d.ts +69 -0
  67. package/dist/core/templates/optional-workflow.js +140 -0
  68. package/dist/core/templates/workflows/apply-change.js +18 -3
  69. package/dist/core/templates/workflows/archive-change.js +70 -21
  70. package/dist/core/templates/workflows/bulk-archive-change.js +85 -21
  71. package/dist/core/templates/workflows/continue-change.js +14 -4
  72. package/dist/core/templates/workflows/explore.js +34 -16
  73. package/dist/core/templates/workflows/ff-change.js +21 -5
  74. package/dist/core/templates/workflows/new-change.js +15 -3
  75. package/dist/core/templates/workflows/onboard.js +76 -41
  76. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  77. package/dist/core/templates/workflows/project-root.js +38 -0
  78. package/dist/core/templates/workflows/propose.js +22 -5
  79. package/dist/core/templates/workflows/sync-specs.js +40 -1
  80. package/dist/core/templates/workflows/update-change.js +66 -27
  81. package/dist/core/templates/workflows/verify-change.js +121 -49
  82. package/dist/core/update.d.ts +12 -0
  83. package/dist/core/update.js +78 -10
  84. package/dist/core/validation/purpose-placeholder.js +24 -9
  85. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  86. package/dist/core/validation/task-checkboxes.js +181 -0
  87. package/dist/core/validation/validator.d.ts +53 -1
  88. package/dist/core/validation/validator.js +160 -45
  89. package/dist/core/version-check.d.ts +14 -7
  90. package/dist/core/version-check.js +63 -17
  91. package/dist/core/worksets.js +6 -4
  92. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  93. package/dist/prompts/searchable-multi-select.js +35 -6
  94. package/dist/telemetry/config.js +14 -2
  95. package/dist/telemetry/index.d.ts +4 -3
  96. package/dist/telemetry/index.js +22 -10
  97. package/dist/telemetry/opt-out.d.ts +17 -0
  98. package/dist/telemetry/opt-out.js +27 -0
  99. package/dist/utils/file-system.js +20 -3
  100. package/dist/utils/line-endings.d.ts +34 -0
  101. package/dist/utils/line-endings.js +46 -0
  102. package/dist/utils/nested-change.d.ts +35 -0
  103. package/dist/utils/nested-change.js +186 -0
  104. package/dist/utils/spec-discovery.d.ts +20 -0
  105. package/dist/utils/spec-discovery.js +59 -0
  106. package/dist/utils/task-progress.d.ts +1 -1
  107. package/dist/utils/task-progress.js +38 -6
  108. package/package.json +5 -5
  109. package/schemas/spec-driven/schema.yaml +18 -3
  110. package/schemas/spec-driven/templates/design.md +2 -0
  111. package/schemas/spec-driven/templates/proposal.md +2 -0
  112. package/schemas/spec-driven/templates/spec.md +2 -0
  113. 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 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
  }
@@ -102,6 +102,26 @@ export declare function retireSpec(update: SpecUpdate, mainSpecsDir: string, opt
102
102
  displacedPath?: string;
103
103
  }>;
104
104
  export declare function finalizeRetiredSpec(target: string, displacedPath: string, mainSpecsDir: string): Promise<void>;
105
+ /**
106
+ * Remove now-empty directories from `startDir` upward, never leaving the real
107
+ * `boundaryDir` and never removing that directory itself.
108
+ *
109
+ * The boundary is a parameter rather than the specs root directly so the walk's
110
+ * containment is stated at the call site, where the root it must not escape is
111
+ * the thing being reasoned about.
112
+ *
113
+ * The guard re-runs every iteration, so stepping to the LEXICAL parent is safe:
114
+ * a parent that is not the real one is simply re-resolved and rejected. Errors
115
+ * are swallowed and end the walk - ENOTEMPTY and ENOENT are correct outcomes (a
116
+ * file arriving mid-walk must win), and a permissions failure leaves an empty
117
+ * directory behind, which the next successful archive clears.
118
+ *
119
+ * Not race-free: an attacker who can swap an ancestor between the check and the
120
+ * `rmdir` could get an empty directory outside the root removed. Closing that
121
+ * needs fd-relative syscalls Node does not expose, and it requires local write
122
+ * access to `openspec/specs` during an archive.
123
+ */
124
+ export declare function pruneEmptyDirs(startDir: string, boundaryDir: string): Promise<void>;
105
125
  /**
106
126
  * Write an updated spec to disk.
107
127
  */
@@ -8,13 +8,14 @@ import { promises as fs } from 'fs';
8
8
  import { randomUUID } from 'crypto';
9
9
  import path from 'path';
10
10
  import chalk from 'chalk';
11
- import { extractRequirementsSection, findMissingCurrentScenarios, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
11
+ import { extractRequirementsSection, diffScenarioNames, describeScenarioBalance, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
12
12
  import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
13
13
  import { buildCodeFenceMask } from './parsers/code-fence.js';
14
14
  import { MarkdownParser } from './parsers/markdown-parser.js';
15
15
  import { MIN_PURPOSE_LENGTH, PURPOSE_PLACEHOLDER_PREFIX, PURPOSE_PLACEHOLDER_SUFFIX, } from './validation/constants.js';
16
16
  import { discoverSpecFiles } from '../utils/spec-discovery.js';
17
17
  import { FileSystemUtils } from '../utils/file-system.js';
18
+ import { matchLineEnding } from '../utils/line-endings.js';
18
19
  function isLexicallyWithin(allowedDirectory, targetPath) {
19
20
  const relative = path.relative(path.resolve(allowedDirectory), path.resolve(targetPath));
20
21
  return (relative === '' ||
@@ -22,7 +23,7 @@ function isLexicallyWithin(allowedDirectory, targetPath) {
22
23
  !relative.startsWith(`..${path.sep}`) &&
23
24
  !path.isAbsolute(relative)));
24
25
  }
25
- function resolveTrustedSpecPath(specsRoot, specPath) {
26
+ function resolveTrustedSpecPath(specsRoot, specPath, projectRoot) {
26
27
  if (!isLexicallyWithin(specsRoot, specPath)) {
27
28
  throw new Error(`Path is outside the allowed directory: ${specPath}`);
28
29
  }
@@ -42,6 +43,14 @@ function resolveTrustedSpecPath(specsRoot, specPath) {
42
43
  // Freeze their canonical location as the trust root so later swaps are
43
44
  // rejected while a nested spec.md link still cannot escape.
44
45
  const root = FileSystemUtils.canonicalizeExistingPath(path.dirname(specPath));
46
+ // An external capability link is deliberate and supported (see
47
+ // assertDiscoveredSpecPath), so it is not refused here. What was wrong is
48
+ // that the write was silent: the CLI reported the in-project path while
49
+ // writing somewhere else entirely, so a link swapped underneath a repo
50
+ // left nothing on screen to notice. Name the real destination instead.
51
+ if (projectRoot && !isLexicallyWithin(FileSystemUtils.canonicalizeExistingPath(projectRoot), root)) {
52
+ process.emitWarning(`Capability '${path.basename(path.dirname(specPath))}' links outside the project; writing to ${root}`, 'OpenSpecExternalSpecWrite');
53
+ }
45
54
  const file = path.join(root, path.basename(specPath));
46
55
  FileSystemUtils.assertPathWithin(root, file);
47
56
  return { root, file };
@@ -69,7 +78,10 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
69
78
  for (const { id, specFile } of discovered) {
70
79
  const targetFile = path.join(mainSpecsDir, ...id.split('/'), 'spec.md');
71
80
  const source = resolveTrustedSpecPath(changeSpecsDir, specFile);
72
- const target = resolveTrustedSpecPath(mainSpecsDir, targetFile);
81
+ // Main specs always live at `<project root>/openspec/specs`, so the
82
+ // project root is the grandparent - a linked capability directory may not
83
+ // leave it.
84
+ const target = resolveTrustedSpecPath(mainSpecsDir, targetFile, path.dirname(path.dirname(mainSpecsDir)));
73
85
  // Check if target exists
74
86
  let exists = false;
75
87
  try {
@@ -110,6 +122,29 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
110
122
  // Parse deltas from the change spec file
111
123
  const plan = parseDeltaSpec(changeContent);
112
124
  const specName = update.id;
125
+ // A FROM:/TO: line that never formed a pair means the RENAMED section does not
126
+ // say what the author meant. Refuse rather than apply the pairing the reader
127
+ // happened to form: with interleaved lines that pairing renames a requirement
128
+ // the delta never named, under a name written for a different one.
129
+ if (plan.unpairedRenames.length > 0) {
130
+ const first = plan.unpairedRenames[0];
131
+ const missing = first.side === 'FROM' ? 'TO' : 'FROM';
132
+ throw new Error(`${specName} validation failed - RENAMED entry on line ${first.line} has no matching ${missing}: ` +
133
+ `for header "### Requirement: ${first.name}". ` +
134
+ `Write each rename as a FROM: line followed immediately by its TO: line.`);
135
+ }
136
+ // A well-formed requirement written outside every delta section is not
137
+ // applied. Say so here as well as in validate: archive is the last point at
138
+ // which the author can still notice, and the block reads exactly like one
139
+ // that would have applied.
140
+ for (const orphan of plan.orphanedRequirements) {
141
+ const where = orphan.section
142
+ ? `under "## ${orphan.section}"`
143
+ : 'above the first "## " section';
144
+ warn(`${specName} - requirement "${orphan.name}" (line ${orphan.line}) is ${where}, ` +
145
+ `which is not a delta section, so it was not applied. ` +
146
+ `Move it under ADDED/MODIFIED/REMOVED/RENAMED Requirements.`);
147
+ }
113
148
  // Pre-validate duplicates within sections
114
149
  const addedNames = new Set();
115
150
  for (const add of plan.added) {
@@ -287,6 +322,13 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
287
322
  if (nameToBlock.has(to)) {
288
323
  throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists`);
289
324
  }
325
+ // A target that differs from another requirement only in case or interior
326
+ // whitespace would leave two copies of one requirement. The source itself
327
+ // is exempt, so a case-only rename of a requirement stays allowed.
328
+ const targetNearMiss = [...nameToBlock.keys()].find((k) => k !== from && foldRequirementName(k) === foldRequirementName(to));
329
+ if (targetNearMiss !== undefined) {
330
+ 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`);
331
+ }
290
332
  const block = nameToBlock.get(from);
291
333
  const newHeader = `### Requirement: ${to}`;
292
334
  const rawLines = block.raw.split('\n');
@@ -342,9 +384,9 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
342
384
  if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) {
343
385
  throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`);
344
386
  }
345
- const missingScenarios = findMissingCurrentScenarios(currentBlock, mod);
346
- if (missingScenarios.length > 0) {
347
- 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.`);
387
+ const scenarioDiff = diffScenarioNames(currentBlock, mod);
388
+ if (scenarioDiff.missing.length > 0) {
389
+ throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${scenarioDiff.missing.map(name => `"${name}"`).join(', ')}. ${describeScenarioBalance(scenarioDiff)} Refresh the change spec before archiving to avoid dropping scenarios.`);
348
390
  }
349
391
  // Identical content means the modification was already synced to the
350
392
  // baseline (early-sync pattern) — count only real replacements, so a
@@ -369,6 +411,15 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
369
411
  }
370
412
  throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`);
371
413
  }
414
+ // A name that differs from an existing requirement only in case or
415
+ // interior whitespace is that requirement written again: adding it would
416
+ // leave two contradicting copies in the spec. Like the exact check above,
417
+ // this compares against the spec as it stands after the earlier operations,
418
+ // so a variant of a requirement this delta removed or renamed away is fine.
419
+ const nearMiss = [...nameToBlock.keys()].find((k) => foldRequirementName(k) === foldRequirementName(key));
420
+ if (nearMiss !== undefined) {
421
+ 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`);
422
+ }
372
423
  nameToBlock.set(key, add);
373
424
  addedApplied++;
374
425
  }
@@ -945,7 +996,7 @@ async function isInsideRealDir(realPath, dir) {
945
996
  * needs fd-relative syscalls Node does not expose, and it requires local write
946
997
  * access to `openspec/specs` during an archive.
947
998
  */
948
- async function pruneEmptyDirs(startDir, boundaryDir) {
999
+ export async function pruneEmptyDirs(startDir, boundaryDir) {
949
1000
  let boundary;
950
1001
  try {
951
1002
  boundary = await fs.realpath(boundaryDir);
@@ -990,11 +1041,23 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
990
1041
  // Create target directory if needed
991
1042
  const targetDir = path.dirname(update.target);
992
1043
  await fs.mkdir(targetDir, { recursive: true });
1044
+ // The parsers normalize CRLF to LF on read, so `rebuilt` is always LF. Write
1045
+ // it back with the convention the file already used, or a Windows checkout
1046
+ // (core.autocrlf=true) sees every line of the spec change when one
1047
+ // requirement moved. A spec that does not exist yet stays LF.
1048
+ // Only a missing file means "no convention to match". Swallowing every error
1049
+ // would read an existing but unreadable spec as absent and rewrite it as LF.
1050
+ const previous = await fs.readFile(update.target, 'utf-8').catch((error) => {
1051
+ if (error.code === 'ENOENT')
1052
+ return undefined;
1053
+ throw error;
1054
+ });
1055
+ const toWrite = previous === undefined ? rebuilt : matchLineEnding(rebuilt, previous);
993
1056
  await options.beforeMutate?.();
994
1057
  // Preserve the established in-place write semantics: symlink referents,
995
1058
  // hard-linked specs, ACLs, extended attributes, and filesystems without hard
996
1059
  // links must continue to behave as they did before capability retirement.
997
- await fs.writeFile(update.target, rebuilt);
1060
+ await fs.writeFile(update.target, toWrite);
998
1061
  if (options.silent)
999
1062
  return;
1000
1063
  const specName = update.id;
@@ -1011,15 +1074,36 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
1011
1074
  /** Blank out `<!-- ... -->` spans, preserving line count so indices stay aligned. */
1012
1075
  function maskHtmlComments(content) {
1013
1076
  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));
1077
+ // Linear scan: every character is visited once. A `/<!--[\s\S]*?--!?>/g`
1078
+ // replace re-scans to end of file from every `<!--`, which is quadratic on a
1079
+ // spec dense in comment openers.
1080
+ let out = '';
1081
+ let index = 0;
1082
+ for (;;) {
1083
+ const open = content.indexOf('<!--', index);
1084
+ if (open === -1)
1085
+ return out + content.slice(index);
1086
+ out += content.slice(index, open);
1087
+ // `--!>` is a comment terminator as well as `-->`.
1088
+ let close = -1;
1089
+ for (let i = open + 4; i < content.length; i++) {
1090
+ if (content.startsWith('-->', i)) {
1091
+ close = i + 3;
1092
+ break;
1093
+ }
1094
+ if (content.startsWith('--!>', i)) {
1095
+ close = i + 4;
1096
+ break;
1097
+ }
1098
+ }
1099
+ // A comment that is never closed runs to end of file, so everything after
1100
+ // it is commented out too. Without this an unterminated `<!--` above a
1101
+ // `## Purpose` left the commented-out header looking real (#1413).
1102
+ if (close === -1)
1103
+ return out + blank(content.slice(open));
1104
+ out += blank(content.slice(open, close));
1105
+ index = close;
1106
+ }
1023
1107
  }
1024
1108
  /**
1025
1109
  * 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>;