@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.
Files changed (105) hide show
  1. package/README.md +3 -15
  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 +170 -16
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.d.ts +8 -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 +17 -1
  17. package/dist/core/artifact-graph/schema.d.ts +14 -0
  18. package/dist/core/artifact-graph/schema.js +52 -0
  19. package/dist/core/artifact-graph/types.js +12 -1
  20. package/dist/core/change-status-policy.d.ts +14 -0
  21. package/dist/core/change-status-policy.js +14 -6
  22. package/dist/core/command-generation/generator.js +2 -0
  23. package/dist/core/completion-tip.js +7 -7
  24. package/dist/core/completions/installers/bash-installer.js +15 -7
  25. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  26. package/dist/core/completions/installers/shell-quote.js +13 -0
  27. package/dist/core/completions/installers/zsh-installer.js +3 -2
  28. package/dist/core/config-schema.d.ts +1 -1
  29. package/dist/core/config.d.ts +16 -0
  30. package/dist/core/config.js +27 -1
  31. package/dist/core/global-config.d.ts +30 -2
  32. package/dist/core/global-config.js +57 -3
  33. package/dist/core/init.js +33 -3
  34. package/dist/core/legacy-cleanup.d.ts +4 -0
  35. package/dist/core/legacy-cleanup.js +180 -12
  36. package/dist/core/list.js +55 -11
  37. package/dist/core/migration.js +6 -1
  38. package/dist/core/onboarding-commands.d.ts +11 -1
  39. package/dist/core/onboarding-commands.js +26 -0
  40. package/dist/core/parsers/change-parser.d.ts +12 -1
  41. package/dist/core/parsers/change-parser.js +85 -87
  42. package/dist/core/parsers/markdown-parser.js +4 -3
  43. package/dist/core/parsers/requirement-blocks.d.ts +44 -3
  44. package/dist/core/parsers/requirement-blocks.js +164 -43
  45. package/dist/core/parsers/requirement-text.d.ts +13 -2
  46. package/dist/core/parsers/requirement-text.js +34 -7
  47. package/dist/core/parsers/spec-structure.js +4 -1
  48. package/dist/core/project-config.d.ts +9 -1
  49. package/dist/core/project-config.js +11 -2
  50. package/dist/core/references.d.ts +20 -0
  51. package/dist/core/references.js +56 -0
  52. package/dist/core/shared/skill-content-equivalence.js +5 -1
  53. package/dist/core/shared/skill-generation.js +36 -8
  54. package/dist/core/shared/tool-detection.js +37 -4
  55. package/dist/core/specs-apply.js +260 -18
  56. package/dist/core/store/git.d.ts +33 -0
  57. package/dist/core/store/git.js +60 -6
  58. package/dist/core/store/operations.d.ts +1 -1
  59. package/dist/core/store/operations.js +32 -10
  60. package/dist/core/store/registry.d.ts +2 -1
  61. package/dist/core/store/registry.js +5 -1
  62. package/dist/core/templates/optional-workflow.d.ts +69 -0
  63. package/dist/core/templates/optional-workflow.js +140 -0
  64. package/dist/core/templates/workflows/apply-change.js +18 -3
  65. package/dist/core/templates/workflows/archive-change.js +50 -15
  66. package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
  67. package/dist/core/templates/workflows/continue-change.js +14 -2
  68. package/dist/core/templates/workflows/explore.js +48 -14
  69. package/dist/core/templates/workflows/ff-change.js +19 -3
  70. package/dist/core/templates/workflows/new-change.js +15 -3
  71. package/dist/core/templates/workflows/onboard.js +67 -37
  72. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  73. package/dist/core/templates/workflows/project-root.js +38 -0
  74. package/dist/core/templates/workflows/propose.js +52 -15
  75. package/dist/core/templates/workflows/sync-specs.js +40 -1
  76. package/dist/core/templates/workflows/update-change.js +52 -25
  77. package/dist/core/templates/workflows/verify-change.js +12 -3
  78. package/dist/core/update.d.ts +34 -0
  79. package/dist/core/update.js +109 -11
  80. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  81. package/dist/core/validation/task-checkboxes.js +181 -0
  82. package/dist/core/validation/validator.d.ts +53 -1
  83. package/dist/core/validation/validator.js +154 -40
  84. package/dist/core/version-check.d.ts +14 -7
  85. package/dist/core/version-check.js +63 -17
  86. package/dist/core/worksets.js +6 -4
  87. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  88. package/dist/prompts/searchable-multi-select.js +35 -6
  89. package/dist/telemetry/config.js +14 -2
  90. package/dist/telemetry/index.d.ts +4 -3
  91. package/dist/telemetry/index.js +22 -10
  92. package/dist/telemetry/opt-out.d.ts +17 -0
  93. package/dist/telemetry/opt-out.js +27 -0
  94. package/dist/utils/nested-change.d.ts +35 -0
  95. package/dist/utils/nested-change.js +186 -0
  96. package/dist/utils/spec-discovery.d.ts +20 -0
  97. package/dist/utils/spec-discovery.js +59 -0
  98. package/dist/utils/task-progress.d.ts +1 -1
  99. package/dist/utils/task-progress.js +38 -6
  100. package/package.json +4 -4
  101. package/schemas/spec-driven/schema.yaml +24 -5
  102. package/schemas/spec-driven/templates/design.md +2 -0
  103. package/schemas/spec-driven/templates/proposal.md +2 -0
  104. package/schemas/spec-driven/templates/spec.md +2 -0
  105. 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
  }
@@ -253,7 +259,34 @@ export function getToolVersionStatus(projectRoot, toolId, currentVersion, option
253
259
  generatedByVersion = currentVersion;
254
260
  }
255
261
  }
256
- const needsUpdate = configured && (generatedByVersion === null || generatedByVersion !== currentVersion);
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,