@fission-ai/openspec 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +28 -1
  2. package/dist/cli/index.js +73 -7
  3. package/dist/commands/change.d.ts +0 -1
  4. package/dist/commands/change.js +90 -50
  5. package/dist/commands/config.js +14 -8
  6. package/dist/commands/doctor.js +9 -4
  7. package/dist/commands/feedback.js +74 -23
  8. package/dist/commands/schema.js +21 -11
  9. package/dist/commands/show.js +5 -2
  10. package/dist/commands/spec.js +20 -24
  11. package/dist/commands/validate.js +18 -3
  12. package/dist/commands/workflow/index.d.ts +1 -1
  13. package/dist/commands/workflow/index.js +1 -1
  14. package/dist/commands/workflow/instructions.d.ts +7 -1
  15. package/dist/commands/workflow/instructions.js +89 -6
  16. package/dist/commands/workflow/shared.d.ts +13 -2
  17. package/dist/commands/workflow/shared.js +34 -4
  18. package/dist/commands/workflow/status.js +7 -2
  19. package/dist/core/archive.d.ts +0 -1
  20. package/dist/core/archive.js +96 -33
  21. package/dist/core/artifact-graph/graph.d.ts +16 -0
  22. package/dist/core/artifact-graph/graph.js +30 -6
  23. package/dist/core/artifact-graph/instruction-loader.d.ts +27 -2
  24. package/dist/core/artifact-graph/instruction-loader.js +55 -7
  25. package/dist/core/artifact-graph/resolver.d.ts +14 -0
  26. package/dist/core/artifact-graph/resolver.js +34 -6
  27. package/dist/core/change-metadata/schema.d.ts +1 -0
  28. package/dist/core/change-metadata/schema.js +6 -0
  29. package/dist/core/change-status-policy.d.ts +1 -1
  30. package/dist/core/command-generation/adapters/amazon-q.d.ts +4 -0
  31. package/dist/core/command-generation/adapters/amazon-q.js +7 -1
  32. package/dist/core/command-generation/adapters/antigravity.js +2 -1
  33. package/dist/core/command-generation/adapters/auggie.js +2 -1
  34. package/dist/core/command-generation/adapters/bob.d.ts +5 -1
  35. package/dist/core/command-generation/adapters/bob.js +6 -5
  36. package/dist/core/command-generation/adapters/claude.js +1 -8
  37. package/dist/core/command-generation/adapters/codebuddy.js +3 -2
  38. package/dist/core/command-generation/adapters/continue.js +3 -2
  39. package/dist/core/command-generation/adapters/costrict.js +2 -1
  40. package/dist/core/command-generation/adapters/crush.js +5 -5
  41. package/dist/core/command-generation/adapters/cursor.js +2 -2
  42. package/dist/core/command-generation/adapters/devin.d.ts +19 -0
  43. package/dist/core/command-generation/adapters/devin.js +36 -0
  44. package/dist/core/command-generation/adapters/factory.js +2 -1
  45. package/dist/core/command-generation/adapters/gemini.js +37 -2
  46. package/dist/core/command-generation/adapters/github-copilot.js +2 -1
  47. package/dist/core/command-generation/adapters/iflow.js +5 -4
  48. package/dist/core/command-generation/adapters/index.d.ts +2 -2
  49. package/dist/core/command-generation/adapters/index.js +2 -2
  50. package/dist/core/command-generation/adapters/junie.js +2 -1
  51. package/dist/core/command-generation/adapters/kiro.js +2 -1
  52. package/dist/core/command-generation/adapters/lingma.js +5 -5
  53. package/dist/core/command-generation/adapters/oh-my-pi.d.ts +2 -2
  54. package/dist/core/command-generation/adapters/oh-my-pi.js +3 -5
  55. package/dist/core/command-generation/adapters/opencode.js +3 -5
  56. package/dist/core/command-generation/adapters/pi.d.ts +2 -2
  57. package/dist/core/command-generation/adapters/pi.js +3 -6
  58. package/dist/core/command-generation/adapters/qoder.js +5 -5
  59. package/dist/core/command-generation/adapters/qwen.d.ts +7 -3
  60. package/dist/core/command-generation/adapters/qwen.js +12 -7
  61. package/dist/core/command-generation/adapters/roocode.d.ts +4 -4
  62. package/dist/core/command-generation/adapters/roocode.js +4 -4
  63. package/dist/core/command-generation/adapters/trae.js +1 -21
  64. package/dist/core/command-generation/adapters/zcode.d.ts +16 -0
  65. package/dist/core/command-generation/adapters/zcode.js +33 -0
  66. package/dist/core/command-generation/generator.d.ts +8 -0
  67. package/dist/core/command-generation/generator.js +15 -1
  68. package/dist/core/command-generation/index.d.ts +1 -1
  69. package/dist/core/command-generation/index.js +1 -1
  70. package/dist/core/command-generation/invocation.d.ts +73 -0
  71. package/dist/core/command-generation/invocation.js +77 -0
  72. package/dist/core/command-generation/registry.js +4 -4
  73. package/dist/core/command-generation/types.d.ts +9 -2
  74. package/dist/core/command-generation/yaml.d.ts +20 -6
  75. package/dist/core/command-generation/yaml.js +31 -21
  76. package/dist/core/command-surface.d.ts +15 -0
  77. package/dist/core/command-surface.js +33 -0
  78. package/dist/core/completions/command-registry.js +8 -2
  79. package/dist/core/completions/generators/powershell-generator.js +15 -8
  80. package/dist/core/completions/installers/zsh-installer.d.ts +9 -0
  81. package/dist/core/completions/installers/zsh-installer.js +20 -4
  82. package/dist/core/config-prompts.d.ts +1 -1
  83. package/dist/core/config-prompts.js +14 -1
  84. package/dist/core/config-schema.d.ts +7 -1
  85. package/dist/core/config-schema.js +40 -1
  86. package/dist/core/config.d.ts +12 -0
  87. package/dist/core/config.js +21 -3
  88. package/dist/core/global-config.d.ts +5 -0
  89. package/dist/core/init.d.ts +35 -0
  90. package/dist/core/init.js +225 -39
  91. package/dist/core/legacy-cleanup.d.ts +59 -0
  92. package/dist/core/legacy-cleanup.js +249 -22
  93. package/dist/core/list.js +5 -7
  94. package/dist/core/migration.d.ts +71 -1
  95. package/dist/core/migration.js +294 -1
  96. package/dist/core/onboarding-commands.d.ts +30 -0
  97. package/dist/core/onboarding-commands.js +38 -0
  98. package/dist/core/parsers/change-parser.d.ts +17 -2
  99. package/dist/core/parsers/change-parser.js +33 -20
  100. package/dist/core/parsers/code-fence.d.ts +15 -0
  101. package/dist/core/parsers/code-fence.js +49 -0
  102. package/dist/core/parsers/markdown-parser.js +2 -1
  103. package/dist/core/parsers/requirement-blocks.d.ts +8 -0
  104. package/dist/core/parsers/requirement-blocks.js +60 -35
  105. package/dist/core/parsers/requirement-text.d.ts +1 -8
  106. package/dist/core/parsers/requirement-text.js +4 -42
  107. package/dist/core/parsers/spec-structure.js +3 -29
  108. package/dist/core/profile-sync-drift.d.ts +0 -8
  109. package/dist/core/profile-sync-drift.js +9 -45
  110. package/dist/core/project-config.d.ts +25 -6
  111. package/dist/core/project-config.js +77 -7
  112. package/dist/core/references.js +42 -4
  113. package/dist/core/relationship-health.d.ts +8 -0
  114. package/dist/core/relationship-health.js +13 -0
  115. package/dist/core/root-selection.d.ts +9 -6
  116. package/dist/core/root-selection.js +42 -5
  117. package/dist/core/shared/tool-detection.d.ts +25 -5
  118. package/dist/core/shared/tool-detection.js +105 -6
  119. package/dist/core/specs-apply.d.ts +8 -33
  120. package/dist/core/specs-apply.js +252 -157
  121. package/dist/core/store/git.d.ts +13 -0
  122. package/dist/core/store/git.js +24 -0
  123. package/dist/core/templates/workflows/apply-change.js +54 -4
  124. package/dist/core/templates/workflows/archive-change.js +166 -42
  125. package/dist/core/templates/workflows/bulk-archive-change.js +240 -63
  126. package/dist/core/templates/workflows/continue-change.js +32 -40
  127. package/dist/core/templates/workflows/explore.js +12 -0
  128. package/dist/core/templates/workflows/ff-change.js +46 -30
  129. package/dist/core/templates/workflows/new-change.js +2 -2
  130. package/dist/core/templates/workflows/onboard.js +4 -4
  131. package/dist/core/templates/workflows/propose.js +50 -32
  132. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  133. package/dist/core/templates/workflows/store-selection.js +1 -1
  134. package/dist/core/templates/workflows/sync-specs.js +172 -22
  135. package/dist/core/templates/workflows/update-change.js +20 -12
  136. package/dist/core/templates/workflows/verify-change.js +16 -10
  137. package/dist/core/update.d.ts +39 -5
  138. package/dist/core/update.js +288 -71
  139. package/dist/core/validation/constants.d.ts +4 -1
  140. package/dist/core/validation/constants.js +4 -1
  141. package/dist/core/validation/validator.d.ts +1 -7
  142. package/dist/core/validation/validator.js +92 -36
  143. package/dist/core/version-check.d.ts +152 -0
  144. package/dist/core/version-check.js +736 -0
  145. package/dist/core/view.js +12 -17
  146. package/dist/prompts/searchable-multi-select.js +1 -1
  147. package/dist/telemetry/index.d.ts +1 -1
  148. package/dist/telemetry/index.js +55 -33
  149. package/dist/ui/welcome-screen.d.ts +9 -1
  150. package/dist/ui/welcome-screen.js +91 -38
  151. package/dist/utils/change-metadata.d.ts +30 -0
  152. package/dist/utils/change-metadata.js +101 -7
  153. package/dist/utils/change-utils.d.ts +10 -4
  154. package/dist/utils/change-utils.js +20 -13
  155. package/dist/utils/command-references.d.ts +80 -6
  156. package/dist/utils/command-references.js +138 -7
  157. package/dist/utils/date.d.ts +8 -0
  158. package/dist/utils/date.js +13 -0
  159. package/dist/utils/index.d.ts +1 -1
  160. package/dist/utils/index.js +1 -1
  161. package/dist/utils/item-discovery.d.ts +19 -0
  162. package/dist/utils/item-discovery.js +30 -48
  163. package/dist/utils/shell-detection.d.ts +1 -1
  164. package/dist/utils/shell-detection.js +64 -11
  165. package/dist/utils/spec-discovery.d.ts +40 -0
  166. package/dist/utils/spec-discovery.js +102 -0
  167. package/package.json +14 -12
  168. package/schemas/spec-driven/schema.yaml +60 -5
  169. package/schemas/spec-driven/templates/design.md +2 -2
  170. package/schemas/spec-driven/templates/proposal.md +5 -1
  171. package/schemas/spec-driven/templates/spec.md +3 -0
  172. package/dist/core/command-generation/adapters/codex.d.ts +0 -16
  173. package/dist/core/command-generation/adapters/codex.js +0 -39
  174. package/dist/core/command-generation/adapters/windsurf.d.ts +0 -14
  175. package/dist/core/command-generation/adapters/windsurf.js +0 -38
@@ -53,6 +53,46 @@ function registerFix(id, remote) {
53
53
  }
54
54
  return `Get a checkout from a teammate and run: openspec store register <path> --id ${id}`;
55
55
  }
56
+ const WHITESPACE = /\s/;
57
+ /**
58
+ * Drop a CommonMark closing sequence (`## Purpose ##`). The closing run only
59
+ * counts when whitespace precedes it, so `Purpose###` keeps its hashes.
60
+ * Scans from the end so the cost stays linear in the title length.
61
+ */
62
+ function stripClosingSequence(title) {
63
+ let end = title.length;
64
+ while (end > 0 && WHITESPACE.test(title[end - 1])) {
65
+ end--;
66
+ }
67
+ const hashEnd = end;
68
+ while (end > 0 && title[end - 1] === '#') {
69
+ end--;
70
+ }
71
+ const noClosingRun = end === hashEnd;
72
+ const missingLeadingSpace = end === 0 || !WHITESPACE.test(title[end - 1]);
73
+ if (noClosingRun || missingLeadingSpace) {
74
+ return title.trim();
75
+ }
76
+ return title.slice(0, end).trim();
77
+ }
78
+ /**
79
+ * Heading title, or null when the line is not an ATX heading. Hand-rolled
80
+ * rather than a regex so a title padded with whitespace cannot backtrack.
81
+ */
82
+ function parseHeadingTitle(line) {
83
+ let level = 0;
84
+ while (level < 6 && level < line.length && line[level] === '#') {
85
+ level++;
86
+ }
87
+ if (level === 0 || level >= line.length || !WHITESPACE.test(line[level])) {
88
+ return null;
89
+ }
90
+ let start = level;
91
+ while (start < line.length && WHITESPACE.test(line[start])) {
92
+ start++;
93
+ }
94
+ return stripClosingSequence(line.slice(start));
95
+ }
56
96
  /**
57
97
  * Tolerant first-Purpose-line extraction. parseSpec() throws on specs
58
98
  * without Purpose/Requirements sections; the index must never fail on an
@@ -79,12 +119,11 @@ export function extractFirstPurposeLine(markdown) {
79
119
  if (fenceMarker !== null) {
80
120
  continue;
81
121
  }
82
- const heading = line.match(/^(#{1,6})\s+(.*)$/);
83
- if (heading) {
122
+ const title = parseHeadingTitle(line);
123
+ if (title !== null) {
84
124
  if (inPurpose) {
85
125
  return '';
86
126
  }
87
- const title = heading[2].replace(/\s+#+\s*$/, '').trim();
88
127
  inPurpose = title.toLowerCase() === 'purpose';
89
128
  continue;
90
129
  }
@@ -152,7 +191,6 @@ export function renderReferencedStoresSection(entries) {
152
191
  * let hostile content forge instruction lines (slice 6.1 hardening).
153
192
  */
154
193
  export function sanitizeInline(value, maxLength = 300) {
155
- // eslint-disable-next-line no-control-regex
156
194
  const flattened = value.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
157
195
  return flattened.length > maxLength ? `${flattened.slice(0, maxLength)}…` : flattened;
158
196
  }
@@ -26,6 +26,10 @@ export interface RelationshipHealth {
26
26
  remote?: string;
27
27
  };
28
28
  origin_url?: string;
29
+ drift?: {
30
+ ahead: number;
31
+ behind: number;
32
+ };
29
33
  status: StoreDiagnostic[];
30
34
  } | null;
31
35
  references: ReferenceIndexEntry[];
@@ -42,6 +46,10 @@ export interface InspectRelationshipsInput {
42
46
  metadataValid: boolean;
43
47
  canonicalRemote?: string;
44
48
  originUrl?: string;
49
+ drift?: {
50
+ ahead: number;
51
+ behind: number;
52
+ };
45
53
  };
46
54
  referenceEntries: ReferenceIndexEntry[];
47
55
  registryUnreadable: boolean;
@@ -37,6 +37,18 @@ export function inspectRelationships(input) {
37
37
  input.storeFacts.canonicalRemote !== input.storeFacts.originUrl) {
38
38
  storeStatus.push(makeStoreDiagnostic('info', 'store_remote_divergence', `The store.yaml remote (${sanitizeInline(input.storeFacts.canonicalRemote, 200)}) differs from the checkout's origin (${sanitizeInline(input.storeFacts.originUrl, 200)}).`, { target: 'store.metadata' }));
39
39
  }
40
+ // Checkout behind its upstream tracking ref: a read-only staleness
41
+ // signal, not a version pin — OpenSpec never syncs stores, so this
42
+ // compares against the local upstream ref, not the live remote.
43
+ // Behind means teammates on newer commits may resolve different specs.
44
+ // Ahead-only is normal (OpenSpec never pushes stores), so it stays quiet.
45
+ const drift = input.storeFacts.drift;
46
+ if (drift && drift.behind > 0) {
47
+ const behindCommits = `${drift.behind} commit${drift.behind === 1 ? '' : 's'}`;
48
+ storeStatus.push(makeStoreDiagnostic('info', 'store_checkout_drift', drift.ahead > 0
49
+ ? `This store checkout has diverged from its upstream tracking branch (${drift.behind} behind, ${drift.ahead} ahead); teammates on newer commits may resolve different specs.`
50
+ : `This store checkout is ${behindCommits} behind its upstream tracking branch; teammates on newer commits may resolve different specs.`, { target: 'store.git' }));
51
+ }
40
52
  store = {
41
53
  id: input.storeFacts.id,
42
54
  metadata: {
@@ -47,6 +59,7 @@ export function inspectRelationships(input) {
47
59
  : {}),
48
60
  },
49
61
  ...(input.storeFacts.originUrl ? { origin_url: input.storeFacts.originUrl } : {}),
62
+ ...(drift ? { drift } : {}),
50
63
  status: storeStatus,
51
64
  };
52
65
  }
@@ -7,8 +7,11 @@
7
7
  * - `--store <id>` selects a registered store's root.
8
8
  * - Without `--store`, the nearest ancestor containing `openspec/` wins.
9
9
  * Leftover workspace view state is never considered a root here.
10
- * - With no nearest root, registered stores produce a selection hint error;
11
- * otherwise commands may treat the current directory as an implicit root.
10
+ * - With no nearest root, a global `defaultStore` (if set) is the last
11
+ * machine-level fallback before the selection hint error.
12
+ * - With no nearest root and no default, registered stores produce a
13
+ * selection hint error; otherwise commands may treat the current
14
+ * directory as an implicit root.
12
15
  *
13
16
  * Diagnostic codes reuse the store taxonomy where an error passes
14
17
  * through unchanged (`invalid_store_id`, metadata parse failures);
@@ -19,7 +22,7 @@
19
22
  * `no_openspec_root`).
20
23
  */
21
24
  import { type PlanningHome } from './planning-home.js';
22
- export type OpenSpecRootSource = 'store' | 'declared' | 'nearest' | 'implicit';
25
+ export type OpenSpecRootSource = 'store' | 'declared' | 'global_default' | 'nearest' | 'implicit';
23
26
  export interface StoreSelectorOptions {
24
27
  store?: string;
25
28
  storePath?: string;
@@ -84,9 +87,9 @@ export interface RootOutput {
84
87
  }
85
88
  export declare function toRootOutput(root: ResolvedOpenSpecRoot): RootOutput;
86
89
  /**
87
- * A store-selected root — explicit `--store` or the declared fallback.
88
- * Cross-root behavior (absolute paths, --store hints, suppressed
89
- * noun-form suggestions) keys on this, never on `source` directly.
90
+ * A store-selected root — explicit `--store`, a declared pointer, or the
91
+ * global default. Cross-root behavior (absolute paths, --store hints,
92
+ * suppressed noun-form suggestions) keys on this, never on `source` directly.
90
93
  */
91
94
  export declare function isStoreSelectedRoot(root: ResolvedOpenSpecRoot): root is ResolvedOpenSpecRoot & {
92
95
  storeId: string;
@@ -7,8 +7,11 @@
7
7
  * - `--store <id>` selects a registered store's root.
8
8
  * - Without `--store`, the nearest ancestor containing `openspec/` wins.
9
9
  * Leftover workspace view state is never considered a root here.
10
- * - With no nearest root, registered stores produce a selection hint error;
11
- * otherwise commands may treat the current directory as an implicit root.
10
+ * - With no nearest root, a global `defaultStore` (if set) is the last
11
+ * machine-level fallback before the selection hint error.
12
+ * - With no nearest root and no default, registered stores produce a
13
+ * selection hint error; otherwise commands may treat the current
14
+ * directory as an implicit root.
12
15
  *
13
16
  * Diagnostic codes reuse the store taxonomy where an error passes
14
17
  * through unchanged (`invalid_store_id`, metadata parse failures);
@@ -26,6 +29,7 @@ import { getStoreRootForBackend } from './store/registry.js';
26
29
  import { inspectOpenSpecRoot } from './openspec-root.js';
27
30
  import { findRepoPlanningRootSync } from './planning-home.js';
28
31
  import { classifyOpenSpecDir, storePointerProblem } from './project-config.js';
32
+ import { getGlobalConfig } from './global-config.js';
29
33
  import { FileSystemUtils } from '../utils/file-system.js';
30
34
  export class RootSelectionError extends Error {
31
35
  diagnostic;
@@ -221,6 +225,32 @@ async function resolveNearestOrDeclaredRoot(nearestRoot, globalDataDir) {
221
225
  throw error;
222
226
  }
223
227
  }
228
+ /**
229
+ * The machine-level fallback: the global `defaultStore` resolved as a root,
230
+ * with its own provenance (`global_default`) so JSON surfaces can tell a
231
+ * machine-wide default from a repo's `store:` pointer. Mirrors the
232
+ * declared-pointer catch — a stale or unregistered id degrades to the
233
+ * underlying error, reshaped to point at clearing the global default
234
+ * rather than passing --store.
235
+ */
236
+ async function resolveDefaultStoreRoot(id, globalDataDir) {
237
+ try {
238
+ return await resolveStoreRoot(id, globalDataDir, 'global_default');
239
+ }
240
+ catch (error) {
241
+ if (error instanceof RootSelectionError) {
242
+ const staleFix = error.diagnostic.code === 'unknown_store' ||
243
+ error.diagnostic.code === 'no_registered_stores'
244
+ ? `Register the store (openspec store register <path> --id ${id}) or clear the stale global default (openspec config unset defaultStore).`
245
+ : error.diagnostic.fix;
246
+ throw new RootSelectionError(`Global defaultStore '${id}': ${error.message}`, error.diagnostic.code, {
247
+ ...(error.diagnostic.target ? { target: error.diagnostic.target } : {}),
248
+ ...(staleFix ? { fix: staleFix } : {}),
249
+ });
250
+ }
251
+ throw error;
252
+ }
253
+ }
224
254
  export async function resolveOpenSpecRoot(options = {}) {
225
255
  if (options.storePath !== undefined) {
226
256
  throw new RootSelectionError('--store-path is not supported. Register the path with openspec store register <path>, then select it with --store <id>.', 'store_path_not_supported', {
@@ -236,6 +266,13 @@ export async function resolveOpenSpecRoot(options = {}) {
236
266
  if (nearestRoot) {
237
267
  return resolveNearestOrDeclaredRoot(nearestRoot, options.globalDataDir);
238
268
  }
269
+ // Machine-level fallback: a global defaultStore is consulted only after
270
+ // --store, the nearest local root, and project-level pointers have all
271
+ // failed to resolve — it changes the failure path, never the precedence.
272
+ const defaultStore = getGlobalConfig().defaultStore;
273
+ if (defaultStore) {
274
+ return resolveDefaultStoreRoot(defaultStore, options.globalDataDir);
275
+ }
239
276
  let registry;
240
277
  try {
241
278
  registry = await readStoreRegistryState(options.globalDataDir ? { globalDataDir: options.globalDataDir } : {});
@@ -265,9 +302,9 @@ export function toRootOutput(root) {
265
302
  };
266
303
  }
267
304
  /**
268
- * A store-selected root — explicit `--store` or the declared fallback.
269
- * Cross-root behavior (absolute paths, --store hints, suppressed
270
- * noun-form suggestions) keys on this, never on `source` directly.
305
+ * A store-selected root — explicit `--store`, a declared pointer, or the
306
+ * global default. Cross-root behavior (absolute paths, --store hints,
307
+ * suppressed noun-form suggestions) keys on this, never on `source` directly.
271
308
  */
272
309
  export function isStoreSelectedRoot(root) {
273
310
  return root.storeId !== undefined;
@@ -32,9 +32,13 @@ export interface ToolVersionStatus {
32
32
  toolId: string;
33
33
  /** The tool's display name */
34
34
  toolName: string;
35
- /** Whether the tool has any skills configured */
35
+ /** Whether the tool has any skills or commands configured */
36
36
  configured: boolean;
37
- /** The generatedBy version found in the skill files, or null if not found */
37
+ /**
38
+ * The generatedBy version recorded in the tool's skill files. For a tool that
39
+ * has commands but no skills, the current version when the command files match
40
+ * what would be generated now. Null when neither says the files are current.
41
+ */
38
42
  generatedByVersion: string | null;
39
43
  /** Whether the tool needs updating (version mismatch or missing) */
40
44
  needsUpdate: boolean;
@@ -47,6 +51,19 @@ export declare function getToolsWithSkillsDir(): string[];
47
51
  * Checks which skill files exist for a tool.
48
52
  */
49
53
  export declare function getToolSkillStatus(projectRoot: string, toolId: string): ToolSkillStatus;
54
+ /**
55
+ * Checks whether a tool has at least one generated OpenSpec command file.
56
+ */
57
+ export declare function toolHasAnyConfiguredCommand(projectPath: string, toolId: string): boolean;
58
+ /**
59
+ * Checks whether command files for a tool on disk match current generated command contents.
60
+ *
61
+ * Command files carry no version stamp, so content equality is the only available
62
+ * "is this current?" signal for a commands-only install.
63
+ */
64
+ export declare function areCommandFilesUpToDate(projectRoot: string, toolId: string, options?: {
65
+ workflows?: readonly string[];
66
+ }): boolean;
50
67
  /**
51
68
  * Gets the skill status for all tools with skillsDir configured.
52
69
  */
@@ -57,11 +74,14 @@ export declare function getToolStates(projectRoot: string): Map<string, ToolSkil
57
74
  */
58
75
  export declare function extractGeneratedByVersion(skillFilePath: string): string | null;
59
76
  /**
60
- * Gets version status for a tool by reading the first available skill file.
77
+ * Gets version status for a tool by reading its skill files, falling back to a
78
+ * command-content fingerprint for installs that have commands but no skills.
61
79
  */
62
- export declare function getToolVersionStatus(projectRoot: string, toolId: string, currentVersion: string): ToolVersionStatus;
80
+ export declare function getToolVersionStatus(projectRoot: string, toolId: string, currentVersion: string, options?: {
81
+ workflows?: readonly string[];
82
+ }): ToolVersionStatus;
63
83
  /**
64
- * Gets all configured tools in the project.
84
+ * Gets all configured tools in the project (configured via skills or commands).
65
85
  */
66
86
  export declare function getConfiguredTools(projectRoot: string): string[];
67
87
  /**
@@ -6,6 +6,10 @@
6
6
  import path from 'path';
7
7
  import * as fs from 'fs';
8
8
  import { AI_TOOLS } from '../config.js';
9
+ import { CommandAdapterRegistry, generateCommands } from '../command-generation/index.js';
10
+ import { getCommandContents } from './skill-generation.js';
11
+ import { getGlobalConfig } from '../global-config.js';
12
+ import { getProfileWorkflows, ALL_WORKFLOWS } from '../profiles.js';
9
13
  /**
10
14
  * Names of skill directories created by openspec init.
11
15
  */
@@ -68,6 +72,88 @@ export function getToolSkillStatus(projectRoot, toolId) {
68
72
  skillCount,
69
73
  };
70
74
  }
75
+ /**
76
+ * Checks whether a tool has at least one generated OpenSpec command file.
77
+ */
78
+ export function toolHasAnyConfiguredCommand(projectPath, toolId) {
79
+ const adapter = CommandAdapterRegistry.get(toolId);
80
+ if (!adapter)
81
+ return false;
82
+ for (const commandId of COMMAND_IDS) {
83
+ const cmdPath = adapter.getFilePath(commandId);
84
+ const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
85
+ if (fs.existsSync(fullPath)) {
86
+ return true;
87
+ }
88
+ }
89
+ return false;
90
+ }
91
+ /**
92
+ * Normalizes checkout artifacts that are not real content drift: a UTF-8 BOM and
93
+ * CRLF line endings, which a Windows clone with `core.autocrlf` reintroduces on
94
+ * every checkout of committed command files.
95
+ */
96
+ function normalizeCommandContent(content) {
97
+ return content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
98
+ }
99
+ /**
100
+ * Checks whether command files for a tool on disk match current generated command contents.
101
+ *
102
+ * Command files carry no version stamp, so content equality is the only available
103
+ * "is this current?" signal for a commands-only install.
104
+ */
105
+ export function areCommandFilesUpToDate(projectRoot, toolId, options) {
106
+ const adapter = CommandAdapterRegistry.get(toolId);
107
+ if (!adapter)
108
+ return false;
109
+ let workflows;
110
+ if (options?.workflows) {
111
+ workflows = options.workflows;
112
+ }
113
+ else {
114
+ try {
115
+ const globalCfg = getGlobalConfig();
116
+ const profile = globalCfg.profile ?? 'core';
117
+ workflows = getProfileWorkflows(profile, globalCfg.workflows);
118
+ }
119
+ catch {
120
+ workflows = ALL_WORKFLOWS;
121
+ }
122
+ }
123
+ const knownWorkflows = workflows.filter((w) => ALL_WORKFLOWS.includes(w));
124
+ const commandContents = getCommandContents(knownWorkflows);
125
+ const generatedCommands = generateCommands(commandContents, adapter);
126
+ if (generatedCommands.length === 0) {
127
+ return false;
128
+ }
129
+ for (const cmd of generatedCommands) {
130
+ const cmdPath = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectRoot, cmd.path);
131
+ if (!fs.existsSync(cmdPath)) {
132
+ return false;
133
+ }
134
+ try {
135
+ const existingContent = fs.readFileSync(cmdPath, 'utf-8');
136
+ if (normalizeCommandContent(existingContent) !== normalizeCommandContent(cmd.fileContent)) {
137
+ return false;
138
+ }
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ }
144
+ // Also check no extra command files exist for deselected workflows
145
+ const desiredWorkflowSet = new Set(knownWorkflows);
146
+ for (const workflow of ALL_WORKFLOWS) {
147
+ if (desiredWorkflowSet.has(workflow))
148
+ continue;
149
+ const cmdPath = adapter.getFilePath(workflow);
150
+ const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectRoot, cmdPath);
151
+ if (fs.existsSync(fullPath)) {
152
+ return false;
153
+ }
154
+ }
155
+ return true;
156
+ }
71
157
  /**
72
158
  * Gets the skill status for all tools with skillsDir configured.
73
159
  */
@@ -109,9 +195,10 @@ export function extractGeneratedByVersion(skillFilePath) {
109
195
  }
110
196
  }
111
197
  /**
112
- * Gets version status for a tool by reading the first available skill file.
198
+ * Gets version status for a tool by reading its skill files, falling back to a
199
+ * command-content fingerprint for installs that have commands but no skills.
113
200
  */
114
- export function getToolVersionStatus(projectRoot, toolId, currentVersion) {
201
+ export function getToolVersionStatus(projectRoot, toolId, currentVersion, options) {
115
202
  const tool = AI_TOOLS.find((t) => t.value === toolId);
116
203
  if (!tool?.skillsDir) {
117
204
  return {
@@ -124,7 +211,7 @@ export function getToolVersionStatus(projectRoot, toolId, currentVersion) {
124
211
  }
125
212
  const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills');
126
213
  let generatedByVersion = null;
127
- // Find the first skill file that exists and read its version
214
+ // 1. Find the first skill file that exists and read its version
128
215
  for (const skillName of SKILL_NAMES) {
129
216
  const skillFile = path.join(skillsDir, skillName, 'SKILL.md');
130
217
  if (fs.existsSync(skillFile)) {
@@ -132,7 +219,15 @@ export function getToolVersionStatus(projectRoot, toolId, currentVersion) {
132
219
  break;
133
220
  }
134
221
  }
135
- const configured = getToolSkillStatus(projectRoot, toolId).configured;
222
+ const skillConfigured = getToolSkillStatus(projectRoot, toolId).configured;
223
+ const commandConfigured = toolHasAnyConfiguredCommand(projectRoot, toolId);
224
+ const configured = skillConfigured || commandConfigured;
225
+ // 2. Commands-only installs have no skill file to read a version from, so fall
226
+ // back to comparing the generated command content. Deliberately skipped when
227
+ // skill files exist: an unreadable version there must still force a rewrite.
228
+ if (!skillConfigured && commandConfigured && areCommandFilesUpToDate(projectRoot, toolId, options)) {
229
+ generatedByVersion = currentVersion;
230
+ }
136
231
  const needsUpdate = configured && (generatedByVersion === null || generatedByVersion !== currentVersion);
137
232
  return {
138
233
  toolId,
@@ -143,11 +238,15 @@ export function getToolVersionStatus(projectRoot, toolId, currentVersion) {
143
238
  };
144
239
  }
145
240
  /**
146
- * Gets all configured tools in the project.
241
+ * Gets all configured tools in the project (configured via skills or commands).
147
242
  */
148
243
  export function getConfiguredTools(projectRoot) {
149
244
  return AI_TOOLS
150
- .filter((t) => t.skillsDir && getToolSkillStatus(projectRoot, t.value).configured)
245
+ .filter((t) => {
246
+ if (!t.skillsDir)
247
+ return false;
248
+ return getToolSkillStatus(projectRoot, t.value).configured || toolHasAnyConfiguredCommand(projectRoot, t.value);
249
+ })
151
250
  .map((t) => t.value);
152
251
  }
153
252
  /**
@@ -5,28 +5,12 @@
5
5
  * Applies delta specs from a change to main specs without archiving.
6
6
  */
7
7
  export interface SpecUpdate {
8
+ /** Capability id relative to the specs root, forward-slash separated (e.g. "web" or "platform/session-layout"). */
9
+ id: string;
8
10
  source: string;
9
11
  target: string;
10
12
  exists: boolean;
11
13
  }
12
- export interface ApplyResult {
13
- capability: string;
14
- added: number;
15
- modified: number;
16
- removed: number;
17
- renamed: number;
18
- }
19
- export interface SpecsApplyOutput {
20
- changeName: string;
21
- capabilities: ApplyResult[];
22
- totals: {
23
- added: number;
24
- modified: number;
25
- removed: number;
26
- renamed: number;
27
- };
28
- noChanges: boolean;
29
- }
30
14
  /**
31
15
  * Find all delta spec files that need to be applied from a change.
32
16
  */
@@ -45,6 +29,7 @@ export declare function buildUpdatedSpec(update: SpecUpdate, changeName: string,
45
29
  removed: number;
46
30
  renamed: number;
47
31
  };
32
+ warnings: string[];
48
33
  }>;
49
34
  /**
50
35
  * Write an updated spec to disk.
@@ -59,20 +44,10 @@ export declare function writeUpdatedSpec(update: SpecUpdate, rebuilt: string, co
59
44
  displayPath?: string;
60
45
  }): Promise<void>;
61
46
  /**
62
- * Build a skeleton spec for new capabilities.
47
+ * Build a skeleton spec for new capabilities. When the delta spec authored a
48
+ * `## Purpose`, carry it over instead of the TBD placeholder (#1413) - archive
49
+ * invents the Purpose for a brand-new main spec either way, and the author's
50
+ * own wording beats a placeholder they then have to hand-edit.
63
51
  */
64
- export declare function buildSpecSkeleton(specFolderName: string, changeName: string): string;
65
- /**
66
- * Apply all delta specs from a change to main specs.
67
- *
68
- * @param projectRoot - The project root directory
69
- * @param changeName - The name of the change to apply
70
- * @param options - Options for the operation
71
- * @returns Result of the operation with counts
72
- */
73
- export declare function applySpecs(projectRoot: string, changeName: string, options?: {
74
- dryRun?: boolean;
75
- skipValidation?: boolean;
76
- silent?: boolean;
77
- }): Promise<SpecsApplyOutput>;
52
+ export declare function buildSpecSkeleton(specFolderName: string, changeName: string, purpose?: string): string;
78
53
  //# sourceMappingURL=specs-apply.d.ts.map