@fission-ai/openspec 1.9.0 → 1.11.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 (72) hide show
  1. package/dist/cli/index.d.ts +17 -0
  2. package/dist/cli/index.js +60 -9
  3. package/dist/commands/change.d.ts +24 -0
  4. package/dist/commands/change.js +223 -0
  5. package/dist/commands/config.js +14 -3
  6. package/dist/commands/feedback.js +28 -7
  7. package/dist/commands/schema.d.ts +5 -0
  8. package/dist/commands/schema.js +201 -35
  9. package/dist/commands/show.js +1 -1
  10. package/dist/commands/workflow/index.d.ts +1 -1
  11. package/dist/commands/workflow/index.js +1 -1
  12. package/dist/commands/workflow/status.d.ts +2 -0
  13. package/dist/commands/workflow/status.js +76 -12
  14. package/dist/core/archive.js +64 -7
  15. package/dist/core/artifact-graph/instruction-loader.js +2 -7
  16. package/dist/core/artifact-graph/outputs.d.ts +4 -0
  17. package/dist/core/artifact-graph/outputs.js +7 -0
  18. package/dist/core/artifact-graph/resolver.js +15 -9
  19. package/dist/core/available-tools.js +16 -1
  20. package/dist/core/command-generation/adapters/antigravity.d.ts +1 -1
  21. package/dist/core/command-generation/adapters/antigravity.js +2 -2
  22. package/dist/core/command-generation/adapters/opencode.d.ts +2 -1
  23. package/dist/core/command-generation/adapters/opencode.js +13 -2
  24. package/dist/core/completion-tip.d.ts +15 -0
  25. package/dist/core/completion-tip.js +133 -0
  26. package/dist/core/completions/command-registry.js +20 -0
  27. package/dist/core/completions/factory.d.ts +11 -0
  28. package/dist/core/completions/generators/fish-generator.d.ts +12 -0
  29. package/dist/core/completions/generators/fish-generator.js +79 -47
  30. package/dist/core/completions/installers/bash-installer.d.ts +7 -0
  31. package/dist/core/completions/installers/bash-installer.js +15 -0
  32. package/dist/core/completions/installers/fish-installer.d.ts +7 -0
  33. package/dist/core/completions/installers/fish-installer.js +15 -0
  34. package/dist/core/completions/installers/powershell-installer.d.ts +7 -0
  35. package/dist/core/completions/installers/powershell-installer.js +15 -0
  36. package/dist/core/completions/templates/fish-templates.d.ts +1 -1
  37. package/dist/core/completions/templates/fish-templates.js +107 -10
  38. package/dist/core/completions/types.d.ts +4 -0
  39. package/dist/core/config-prompts.js +19 -10
  40. package/dist/core/config-schema.d.ts +1 -0
  41. package/dist/core/config-schema.js +2 -0
  42. package/dist/core/config.js +7 -1
  43. package/dist/core/global-config.d.ts +2 -0
  44. package/dist/core/init.d.ts +5 -0
  45. package/dist/core/init.js +117 -14
  46. package/dist/core/legacy-cleanup.js +6 -0
  47. package/dist/core/migration.js +42 -12
  48. package/dist/core/parsers/requirement-blocks.d.ts +1 -0
  49. package/dist/core/parsers/requirement-blocks.js +2 -0
  50. package/dist/core/profiles.d.ts +1 -1
  51. package/dist/core/profiles.js +11 -2
  52. package/dist/core/shared/tool-detection.js +9 -3
  53. package/dist/core/shared-skill-target.d.ts +13 -0
  54. package/dist/core/shared-skill-target.js +43 -1
  55. package/dist/core/specs-apply.js +16 -8
  56. package/dist/core/templates/workflows/explore.js +79 -73
  57. package/dist/core/templates/workflows/feedback.js +1 -0
  58. package/dist/core/templates/workflows/onboard.js +4 -4
  59. package/dist/core/update.js +39 -26
  60. package/dist/core/validation/constants.d.ts +3 -0
  61. package/dist/core/validation/constants.js +12 -0
  62. package/dist/core/validation/purpose-placeholder.d.ts +45 -0
  63. package/dist/core/validation/purpose-placeholder.js +117 -0
  64. package/dist/core/validation/validator.js +16 -1
  65. package/dist/telemetry/index.js +3 -2
  66. package/dist/utils/change-metadata.js +17 -13
  67. package/dist/utils/change-utils.js +5 -0
  68. package/dist/utils/requirement-diff.d.ts +42 -0
  69. package/dist/utils/requirement-diff.js +61 -0
  70. package/package.json +4 -6
  71. package/schemas/spec-driven/schema.yaml +17 -7
  72. package/scripts/postinstall.js +0 -83
@@ -20,6 +20,23 @@ export declare function getCommandPath(command: Command): string;
20
20
  * non-JSON run, never lost, whereas printing it on a JSON run corrupts stdout.
21
21
  */
22
22
  export declare function isJsonRun(command: Command): boolean;
23
+ /**
24
+ * True for the commands that exist to serve shell completions: the user-facing
25
+ * `openspec completion ...` group and the hidden `__complete` resolver that
26
+ * generated completion scripts call on every Tab press. Tipping either about
27
+ * completions is noise, and `__complete` would burn the one-shot tip invisibly.
28
+ */
29
+ export declare function isCompletionRun(commandPath: string): boolean;
30
+ /**
31
+ * True when the first-run completions tip must be deferred rather than shown.
32
+ *
33
+ * Deferring keeps the tip unconsumed, so it still reaches the user on a later
34
+ * run that can actually carry it. All three cases are runs nobody would read a
35
+ * hint from: JSON output, the completion machinery itself, and a stderr that is
36
+ * not a terminal — pipes and the agent-driven runs that dominate this CLI's
37
+ * usage would otherwise burn the user's one-shot tip into a log nobody opens.
38
+ */
39
+ export declare function shouldDeferCompletionTip(command: Command, stderrIsTty: boolean): boolean;
23
40
  export { program };
24
41
  export declare function runCli(argv?: string[]): void;
25
42
  //# sourceMappingURL=index.d.ts.map
package/dist/cli/index.js CHANGED
@@ -24,8 +24,9 @@ import { registerStoreCommand } from '../commands/store.js';
24
24
  import { registerDoctorCommand } from '../commands/doctor.js';
25
25
  import { registerContextCommand } from '../commands/context.js';
26
26
  import { registerWorksetCommand } from '../commands/workset.js';
27
- import { statusCommand, instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, templatesCommand, schemasCommand, newChangeCommand, DEFAULT_SCHEMA, } from '../commands/workflow/index.js';
27
+ import { statusCommand, BATCH_STATUS_FAILURE_PAYLOAD, instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, templatesCommand, schemasCommand, newChangeCommand, DEFAULT_SCHEMA, } from '../commands/workflow/index.js';
28
28
  import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
29
+ import { maybeShowCompletionTip } from '../core/completion-tip.js';
29
30
  import { COMMON_FLAGS } from '../core/completions/shared-flags.js';
30
31
  import { isInteractive } from '../utils/interactive.js';
31
32
  const STORE_OPTION_DESCRIPTION = COMMON_FLAGS.store.description;
@@ -90,6 +91,27 @@ export function isJsonRun(command) {
90
91
  return (command.optsWithGlobals().json === true ||
91
92
  command.args.includes('--json'));
92
93
  }
94
+ /**
95
+ * True for the commands that exist to serve shell completions: the user-facing
96
+ * `openspec completion ...` group and the hidden `__complete` resolver that
97
+ * generated completion scripts call on every Tab press. Tipping either about
98
+ * completions is noise, and `__complete` would burn the one-shot tip invisibly.
99
+ */
100
+ export function isCompletionRun(commandPath) {
101
+ return commandPath.split(':')[0] === 'completion' || commandPath === '__complete';
102
+ }
103
+ /**
104
+ * True when the first-run completions tip must be deferred rather than shown.
105
+ *
106
+ * Deferring keeps the tip unconsumed, so it still reaches the user on a later
107
+ * run that can actually carry it. All three cases are runs nobody would read a
108
+ * hint from: JSON output, the completion machinery itself, and a stderr that is
109
+ * not a terminal — pipes and the agent-driven runs that dominate this CLI's
110
+ * usage would otherwise burn the user's one-shot tip into a log nobody opens.
111
+ */
112
+ export function shouldDeferCompletionTip(command, stderrIsTty) {
113
+ return isJsonRun(command) || isCompletionRun(getCommandPath(command)) || !stderrIsTty;
114
+ }
93
115
  program
94
116
  .name('openspec')
95
117
  .description('AI-native system for spec-driven development')
@@ -105,16 +127,32 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
105
127
  if (opts.color === false) {
106
128
  process.env.NO_COLOR = '1';
107
129
  }
108
- // Show first-run telemetry notice (if not seen). Suppress it whenever the run
109
- // asked for JSON so stdout stays a single valid JSON document (see isJsonRun).
130
+ // Show first-run telemetry notice (if not seen). It's written to stderr, so it
131
+ // never pollutes stdout — but --json runs still defer it (see isJsonRun) so the
132
+ // very first invocation stays free of any incidental output on either stream.
110
133
  await maybeShowTelemetryNotice({ silent: isJsonRun(actionCommand) });
111
134
  // Track command execution (use actionCommand to get the actual subcommand)
112
135
  const commandPath = getCommandPath(actionCommand);
113
136
  await trackCommand(commandPath, version);
114
137
  });
115
138
  // Shutdown telemetry after command completes
116
- program.hook('postAction', async () => {
117
- await shutdown();
139
+ program.hook('postAction', async (_thisCommand, actionCommand) => {
140
+ // Show the first-run shell-completions tip (on stderr, so piped stdout stays
141
+ // clean). postAction, not preAction: the tip trails the command's own output
142
+ // instead of pushing an error message or `init`'s setup summary down the
143
+ // screen. Deferred — not consumed — whenever nobody would read it: JSON runs,
144
+ // `openspec completion ...`, and a stderr that is not a terminal (agents and
145
+ // pipes would otherwise silently burn the user's one-shot tip).
146
+ try {
147
+ await maybeShowCompletionTip({
148
+ silent: shouldDeferCompletionTip(actionCommand, Boolean(process.stderr.isTTY)),
149
+ });
150
+ }
151
+ finally {
152
+ // The flush runs even if the hint throws: parse() is synchronous, so a
153
+ // rejection here has no catch anywhere above it.
154
+ await shutdown();
155
+ }
118
156
  });
119
157
  const availableToolIds = AI_TOOLS
120
158
  .filter((tool) => tool.skillsDir || tool.globalSkillsDir)
@@ -127,6 +165,7 @@ program
127
165
  .command('init [path]')
128
166
  .description('Initialize OpenSpec in your project')
129
167
  .option('--tools <tools>', toolsOptionDescription)
168
+ .option('--language <language>', 'Write new OpenSpec artifacts in this language')
130
169
  .option('--force', 'Auto-cleanup legacy files without prompting')
131
170
  .option('--profile <profile>', 'Override global config profile (core or custom)')
132
171
  .option('--no-animation', 'Show a static welcome screen instead of the animated one')
@@ -157,6 +196,7 @@ program
157
196
  const { InitCommand } = await import('../core/init.js');
158
197
  const initCommand = new InitCommand({
159
198
  tools: options?.tools,
199
+ language: options?.language,
160
200
  force: options?.force,
161
201
  profile: options?.profile,
162
202
  animation: options?.animation,
@@ -326,6 +366,7 @@ changeCmd
326
366
  .option('--json', 'Output as JSON')
327
367
  .option('--deltas-only', 'Show only deltas (JSON only)')
328
368
  .option('--requirements-only', 'Alias for --deltas-only (deprecated)')
369
+ .option('--diff', 'Show per-requirement diffs for delta specs')
329
370
  .option('--no-interactive', 'Disable interactive prompts')
330
371
  .action(async (changeName, options) => {
331
372
  try {
@@ -362,10 +403,12 @@ changeCmd
362
403
  .action(async (changeName, options) => {
363
404
  try {
364
405
  const changeCommand = new ChangeCommand();
406
+ // validate() already sets process.exitCode, and Node honours it at
407
+ // natural exit. Calling process.exit() here would skip commander's
408
+ // postAction hook — the same trap called out for `update` below — which
409
+ // kills the telemetry flush and the first-run completions tip on what is
410
+ // a routine outcome, not an error: a change that fails validation.
365
411
  await changeCommand.validate(changeName, options);
366
- if (typeof process.exitCode === 'number' && process.exitCode !== 0) {
367
- process.exit(process.exitCode);
368
- }
369
412
  }
370
413
  catch (error) {
371
414
  console.error(`Error: ${error.message}`);
@@ -433,6 +476,7 @@ program
433
476
  // change-only flags
434
477
  .option('--deltas-only', 'Show only deltas (JSON only, change)')
435
478
  .option('--requirements-only', 'Alias for --deltas-only (deprecated, change)')
479
+ .option('--diff', 'Show per-requirement diffs for delta specs (change)')
436
480
  // spec-only flags
437
481
  .option('--requirements', 'JSON only: Show only requirements (exclude scenarios)')
438
482
  .option('--no-scenarios', 'JSON only: Exclude scenario content')
@@ -535,6 +579,7 @@ program
535
579
  .command('status')
536
580
  .description('Display artifact completion status for a change')
537
581
  .option('--change <id>', 'Change name to show status for')
582
+ .option('--all', 'Show status for all active changes')
538
583
  .option('--schema <name>', 'Schema override (auto-detected from config.yaml)')
539
584
  .option('--json', 'Output as JSON')
540
585
  .option('--store <id>', STORE_OPTION_DESCRIPTION)
@@ -544,7 +589,13 @@ program
544
589
  await statusCommand(options);
545
590
  }
546
591
  catch (error) {
547
- failWithError(error, { enabled: options.json, fallbackCode: 'change_error' });
592
+ failWithError(error, {
593
+ enabled: options.json,
594
+ // The batch null-shape; the single-change failure shape is
595
+ // pre-existing contract and stays payload-free.
596
+ payload: options.all ? BATCH_STATUS_FAILURE_PAYLOAD : undefined,
597
+ fallbackCode: 'change_error',
598
+ });
548
599
  process.exit(1);
549
600
  }
550
601
  });
@@ -4,19 +4,43 @@ export declare class ChangeCommand {
4
4
  private rootPath?;
5
5
  constructor(rootPath?: string);
6
6
  private getChangesPath;
7
+ private getSpecsPath;
7
8
  /**
8
9
  * Show a change proposal.
9
10
  * - Text mode: raw markdown passthrough (no filters)
10
11
  * - JSON mode: minimal object with deltas; --deltas-only returns same object with filtered deltas
11
12
  * Note: --requirements-only is deprecated alias for --deltas-only
13
+ * - --diff: per-requirement diffs of the delta specs against the main specs,
14
+ * appended in text mode and attached to MODIFIED deltas in JSON mode
12
15
  */
13
16
  show(changeName?: string, options?: {
14
17
  json?: boolean;
15
18
  requirementsOnly?: boolean;
16
19
  deltasOnly?: boolean;
20
+ diff?: boolean;
17
21
  noInteractive?: boolean;
18
22
  rootOutput?: RootOutput;
19
23
  }): Promise<void>;
24
+ /**
25
+ * Read every delta spec under the change and pair each requirement with its
26
+ * counterpart in the main spec. Text mode and JSON mode both render from this
27
+ * one pass, so the two surfaces cannot drift apart.
28
+ */
29
+ private collectSpecDiffs;
30
+ /**
31
+ * Attach `diff` (or `warning`) to every MODIFIED delta in the JSON payload.
32
+ * Mutates the deltas array in place.
33
+ *
34
+ * The parsed Delta objects carry the requirement body in `description`, not
35
+ * the header name, so they are matched to parsed blocks by capability and
36
+ * source order: ChangeParser emits one Delta per MODIFIED block in that order.
37
+ */
38
+ private enrichDeltasWithDiffs;
39
+ /**
40
+ * Text mode: per-requirement diffs of the delta specs against the main specs.
41
+ */
42
+ private showSpecDiffs;
43
+ private printDiffText;
20
44
  /**
21
45
  * List active changes.
22
46
  * - Text default: IDs only; --long prints minimal details (title, counts)
@@ -1,5 +1,6 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
+ import chalk from 'chalk';
3
4
  import { JsonConverter } from '../core/converters/json-converter.js';
4
5
  import { Validator } from '../core/validation/validator.js';
5
6
  import { VALIDATION_MESSAGES } from '../core/validation/constants.js';
@@ -8,6 +9,9 @@ import { isInteractive } from '../utils/interactive.js';
8
9
  import { getActiveChangeIds } from '../utils/item-discovery.js';
9
10
  import { getTaskProgressForChange } from '../utils/task-progress.js';
10
11
  import { FileSystemUtils } from '../utils/file-system.js';
12
+ import { discoverSpecFiles } from '../utils/spec-discovery.js';
13
+ import { foldRequirementName, parseDeltaSpec, } from '../core/parsers/requirement-blocks.js';
14
+ import { extractRequirementBlock, diffRequirementBlock, buildRenameMap, } from '../utils/requirement-diff.js';
11
15
  /**
12
16
  * True only when `target` is definitively absent. An EACCES or I/O failure
13
17
  * means existence cannot be determined, so callers fall through to their
@@ -39,11 +43,18 @@ export class ChangeCommand {
39
43
  getChangesPath() {
40
44
  return path.join(this.rootPath ?? process.cwd(), 'openspec', 'changes');
41
45
  }
46
+ // Main specs resolve against the same root as changes, so `--diff` reads the
47
+ // selected store's specs rather than whatever sits under the cwd.
48
+ getSpecsPath() {
49
+ return path.join(this.rootPath ?? process.cwd(), 'openspec', 'specs');
50
+ }
42
51
  /**
43
52
  * Show a change proposal.
44
53
  * - Text mode: raw markdown passthrough (no filters)
45
54
  * - JSON mode: minimal object with deltas; --deltas-only returns same object with filtered deltas
46
55
  * Note: --requirements-only is deprecated alias for --deltas-only
56
+ * - --diff: per-requirement diffs of the delta specs against the main specs,
57
+ * appended in text mode and attached to MODIFIED deltas in JSON mode
47
58
  */
48
59
  async show(changeName, options) {
49
60
  const changesPath = this.getChangesPath();
@@ -109,6 +120,9 @@ export class ChangeCommand {
109
120
  const title = this.extractTitle(contentForTitle, changeName);
110
121
  const id = parsed.name;
111
122
  const deltas = parsed.deltas || [];
123
+ if (options.diff) {
124
+ await this.enrichDeltasWithDiffs(deltas, changeName, changesPath);
125
+ }
112
126
  const output = {
113
127
  id,
114
128
  title,
@@ -122,6 +136,215 @@ export class ChangeCommand {
122
136
  FileSystemUtils.assertPathWithin(changeDir, proposalPath);
123
137
  const content = await fs.readFile(proposalPath, 'utf-8');
124
138
  console.log(content);
139
+ if (options?.diff) {
140
+ await this.showSpecDiffs(changeName, changesPath);
141
+ }
142
+ }
143
+ }
144
+ /**
145
+ * Read every delta spec under the change and pair each requirement with its
146
+ * counterpart in the main spec. Text mode and JSON mode both render from this
147
+ * one pass, so the two surfaces cannot drift apart.
148
+ */
149
+ async collectSpecDiffs(changeName, changesPath) {
150
+ const specsDir = path.join(changesPath, changeName, 'specs');
151
+ const mainSpecsDir = this.getSpecsPath();
152
+ // Same discovery ChangeParser uses, so a nested capability (specs/<area>/<id>)
153
+ // is diffed rather than silently skipped, and the ids here match the `spec`
154
+ // field of the JSON deltas.
155
+ const discovered = await discoverSpecFiles(specsDir);
156
+ const capabilities = discovered.map(spec => spec.id);
157
+ const results = [];
158
+ for (const { id: capability, specFile: deltaSpecPath } of discovered) {
159
+ const deltaContent = await fs.readFile(deltaSpecPath, 'utf-8');
160
+ const mainSpecPath = path.join(mainSpecsDir, ...capability.split('/'), 'spec.md');
161
+ let mainContent = null;
162
+ try {
163
+ FileSystemUtils.assertPathWithin(mainSpecsDir, mainSpecPath);
164
+ mainContent = await fs.readFile(mainSpecPath, 'utf-8');
165
+ }
166
+ catch (error) {
167
+ if (error?.code !== 'ENOENT')
168
+ throw error;
169
+ // No main spec on disk. For ADDED requirements that is the ordinary new
170
+ // capability case; MODIFIED requirements are handled as a mismatch below.
171
+ }
172
+ const plan = parseDeltaSpec(deltaContent);
173
+ const renameMap = buildRenameMap(plan.renamed);
174
+ for (const block of plan.added) {
175
+ results.push({ capability, operation: 'ADDED', requirementName: block.name, raw: block.raw });
176
+ }
177
+ // Prefer the authored REMOVED block so its Reason/Migration text reaches
178
+ // the reader; the bullet-list form carries a name and nothing else.
179
+ const removedBlocks = new Map(plan.removedBlocks.map(block => [foldRequirementName(block.name), block.raw]));
180
+ for (const name of plan.removed) {
181
+ const raw = removedBlocks.get(foldRequirementName(name));
182
+ results.push({
183
+ capability,
184
+ operation: 'REMOVED',
185
+ requirementName: name,
186
+ raw: raw ?? `### Requirement: ${name}`,
187
+ });
188
+ }
189
+ for (const rename of plan.renamed) {
190
+ results.push({ capability, operation: 'RENAMED', requirementName: rename.to, raw: '', rename });
191
+ }
192
+ for (const block of plan.modified) {
193
+ const entry = {
194
+ capability,
195
+ operation: 'MODIFIED',
196
+ requirementName: block.name,
197
+ raw: block.raw,
198
+ };
199
+ // A requirement renamed and modified in the same delta still lives in
200
+ // the main spec under its old name, so look it up there.
201
+ const oldName = renameMap.get(foldRequirementName(block.name));
202
+ const lookupName = oldName ?? block.name;
203
+ const match = mainContent ? extractRequirementBlock(mainContent, lookupName) : null;
204
+ if (match) {
205
+ entry.diff = diffRequirementBlock(match.raw, block.raw, `${capability}/${block.name}`);
206
+ if (!match.exact) {
207
+ // Archive matches requirement names exactly, so a header that
208
+ // differs only in case or spacing will not merge. Show the diff the
209
+ // author meant, and name the mismatch while it is still cheap to fix.
210
+ entry.warning =
211
+ `Header differs from the main spec's "${match.name}" only in case or spacing; ` +
212
+ `archive matches names exactly, so reconcile them before archiving`;
213
+ }
214
+ }
215
+ else if (mainContent) {
216
+ entry.warning = `No matching main requirement found for "${lookupName}" in ${capability}`;
217
+ }
218
+ else {
219
+ // A MODIFIED requirement names a block that should already exist, so a
220
+ // missing main spec is an authoring error, not a new capability.
221
+ // Rendering it as all-additions would hide what archive will reject.
222
+ entry.warning =
223
+ `No main spec at openspec/specs/${capability}/spec.md, ` +
224
+ `so MODIFIED requirement "${block.name}" has nothing to diff against`;
225
+ }
226
+ results.push(entry);
227
+ }
228
+ }
229
+ return { capabilities, results };
230
+ }
231
+ /**
232
+ * Attach `diff` (or `warning`) to every MODIFIED delta in the JSON payload.
233
+ * Mutates the deltas array in place.
234
+ *
235
+ * The parsed Delta objects carry the requirement body in `description`, not
236
+ * the header name, so they are matched to parsed blocks by capability and
237
+ * source order: ChangeParser emits one Delta per MODIFIED block in that order.
238
+ */
239
+ async enrichDeltasWithDiffs(deltas, changeName, changesPath) {
240
+ const modifiedDeltasBySpec = new Map();
241
+ for (const delta of deltas) {
242
+ if (!delta.spec || delta.operation !== 'MODIFIED')
243
+ continue;
244
+ const list = modifiedDeltasBySpec.get(delta.spec) ?? [];
245
+ list.push(delta);
246
+ modifiedDeltasBySpec.set(delta.spec, list);
247
+ }
248
+ if (modifiedDeltasBySpec.size === 0)
249
+ return;
250
+ const { results } = await this.collectSpecDiffs(changeName, changesPath);
251
+ const modifiedEntriesBySpec = new Map();
252
+ for (const entry of results) {
253
+ if (entry.operation !== 'MODIFIED')
254
+ continue;
255
+ const list = modifiedEntriesBySpec.get(entry.capability) ?? [];
256
+ list.push(entry);
257
+ modifiedEntriesBySpec.set(entry.capability, list);
258
+ }
259
+ for (const [capability, modifiedDeltas] of modifiedDeltasBySpec) {
260
+ const entries = modifiedEntriesBySpec.get(capability) ?? [];
261
+ for (let i = 0; i < modifiedDeltas.length && i < entries.length; i++) {
262
+ const entry = entries[i];
263
+ if (entry.diff !== undefined) {
264
+ modifiedDeltas[i].diff = entry.diff;
265
+ }
266
+ if (entry.warning !== undefined) {
267
+ modifiedDeltas[i].warning = entry.warning;
268
+ }
269
+ }
270
+ }
271
+ }
272
+ /**
273
+ * Text mode: per-requirement diffs of the delta specs against the main specs.
274
+ */
275
+ async showSpecDiffs(changeName, changesPath) {
276
+ const { capabilities, results } = await this.collectSpecDiffs(changeName, changesPath);
277
+ console.log();
278
+ if (capabilities.length === 0 || results.length === 0) {
279
+ // Not an error: a change can be proposal-only. Saying so beats printing a
280
+ // heading with nothing under it.
281
+ console.log(`No delta specs to diff for change "${changeName}".`);
282
+ return;
283
+ }
284
+ console.log(chalk.bold('Specifications Changed (diffs)'));
285
+ console.log();
286
+ this.printDiffText(results);
287
+ }
288
+ printDiffText(results) {
289
+ let currentCap = '';
290
+ for (const r of results) {
291
+ if (r.capability !== currentCap) {
292
+ if (currentCap)
293
+ console.log();
294
+ currentCap = r.capability;
295
+ console.log(chalk.bold.underline(currentCap));
296
+ console.log();
297
+ }
298
+ switch (r.operation) {
299
+ case 'ADDED':
300
+ console.log(chalk.green.bold(` ADDED: ${r.requirementName}`));
301
+ for (const line of r.raw.split('\n')) {
302
+ console.log(chalk.green(` ${line}`));
303
+ }
304
+ console.log();
305
+ break;
306
+ case 'REMOVED':
307
+ console.log(chalk.red.bold(` REMOVED: ${r.requirementName}`));
308
+ for (const line of r.raw.split('\n')) {
309
+ console.log(chalk.red(` ${line}`));
310
+ }
311
+ console.log();
312
+ break;
313
+ case 'RENAMED':
314
+ console.log(chalk.cyan.bold(` RENAMED: ${r.rename?.from} → ${r.rename?.to}`));
315
+ console.log();
316
+ break;
317
+ case 'MODIFIED':
318
+ console.log(chalk.yellow.bold(` MODIFIED: ${r.requirementName}`));
319
+ if (r.warning) {
320
+ console.log(chalk.yellow(` ⚠ ${r.warning}`));
321
+ }
322
+ // A near-miss header carries both: the warning about the mismatch and
323
+ // the diff against the block it almost matched.
324
+ if (r.diff === undefined) {
325
+ for (const line of r.raw.split('\n')) {
326
+ console.log(` ${line}`);
327
+ }
328
+ }
329
+ else if (r.diff === '') {
330
+ console.log(chalk.dim(' (no textual changes)'));
331
+ }
332
+ else {
333
+ for (const line of r.diff.split('\n')) {
334
+ if (line.startsWith('+')) {
335
+ console.log(chalk.green(` ${line}`));
336
+ }
337
+ else if (line.startsWith('-')) {
338
+ console.log(chalk.red(` ${line}`));
339
+ }
340
+ else {
341
+ console.log(` ${line}`);
342
+ }
343
+ }
344
+ }
345
+ console.log();
346
+ break;
347
+ }
125
348
  }
126
349
  }
127
350
  /**
@@ -453,6 +453,7 @@ export function registerConfigCommand(program) {
453
453
  delivery: currentState.delivery,
454
454
  workflows: [...currentState.workflows],
455
455
  };
456
+ let workflowSelectionChanged = false;
456
457
  if (action === 'both' || action === 'delivery') {
457
458
  const deliveryChoices = [
458
459
  {
@@ -497,8 +498,11 @@ export function registerConfigCommand(program) {
497
498
  };
498
499
  };
499
500
  const selectedWorkflows = await checkbox({
501
+ // The `instructions` option was removed in @inquirer/checkbox v5.
502
+ // Its replacement, the built-in keys help tip, renders
503
+ // "↑↓ navigate • space select • ⏎ submit" by default — a superset of
504
+ // the hint this used to pass — so no theme override is needed here.
500
505
  message: 'Select workflows to make available:',
501
- instructions: 'Space to toggle, Enter to confirm',
502
506
  pageSize: ALL_WORKFLOWS.length,
503
507
  theme: {
504
508
  icon: {
@@ -509,7 +513,12 @@ export function registerConfigCommand(program) {
509
513
  choices: ALL_WORKFLOWS.map(formatWorkflowChoice),
510
514
  });
511
515
  nextState.workflows = selectedWorkflows;
512
- nextState.profile = deriveProfileFromWorkflowSelection(selectedWorkflows);
516
+ workflowSelectionChanged =
517
+ selectedWorkflows.length !== currentState.workflows.length ||
518
+ selectedWorkflows.some((workflow) => !currentState.workflows.includes(workflow));
519
+ nextState.profile = workflowSelectionChanged
520
+ ? deriveProfileFromWorkflowSelection(selectedWorkflows)
521
+ : currentState.profile;
513
522
  }
514
523
  const diff = diffProfileState(currentState, nextState);
515
524
  if (!diff.hasChanges) {
@@ -524,7 +533,9 @@ export function registerConfigCommand(program) {
524
533
  console.log();
525
534
  config.profile = nextState.profile;
526
535
  config.delivery = nextState.delivery;
527
- config.workflows = nextState.workflows;
536
+ if (currentState.profile !== 'custom' || workflowSelectionChanged) {
537
+ config.workflows = nextState.workflows;
538
+ }
528
539
  saveGlobalConfig(config);
529
540
  // Check if inside an OpenSpec project
530
541
  const projectDir = process.cwd();
@@ -2,6 +2,8 @@ import { execSync, execFileSync } from 'child_process';
2
2
  import { createRequire } from 'module';
3
3
  import os from 'os';
4
4
  const require = createRequire(import.meta.url);
5
+ const MAX_TITLE_LENGTH = 72;
6
+ const TITLE_PREFIX = 'Feedback: ';
5
7
  /**
6
8
  * Check if gh CLI is installed and available in PATH
7
9
  * Uses platform-appropriate command: 'where' on Windows, 'which' on Unix/macOS
@@ -69,18 +71,37 @@ Submitted via OpenSpec CLI
69
71
  * Format the feedback title
70
72
  */
71
73
  function formatTitle(message) {
72
- return `Feedback: ${message}`;
74
+ const normalizedMessage = message.replace(/\s+/g, ' ').trim();
75
+ const title = `${TITLE_PREFIX}${normalizedMessage}`;
76
+ if (Array.from(title).length <= MAX_TITLE_LENGTH) {
77
+ return title;
78
+ }
79
+ const availableLength = MAX_TITLE_LENGTH - TITLE_PREFIX.length - 1;
80
+ let candidate = '';
81
+ let candidateLength = 0;
82
+ const segments = new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(normalizedMessage);
83
+ for (const { segment } of segments) {
84
+ const segmentLength = Array.from(segment).length;
85
+ if (candidateLength + segmentLength > availableLength) {
86
+ break;
87
+ }
88
+ candidate += segment;
89
+ candidateLength += segmentLength;
90
+ }
91
+ candidate = candidate.trimEnd();
92
+ const lastSpace = candidate.lastIndexOf(' ');
93
+ const summary = lastSpace > 0 ? candidate.slice(0, lastSpace) : candidate;
94
+ return `${TITLE_PREFIX}${summary}…`;
73
95
  }
74
96
  /**
75
97
  * Format the full feedback body
76
98
  */
77
- function formatBody(bodyText) {
78
- const parts = [];
99
+ function formatBody(message, bodyText) {
100
+ const parts = ['## Summary', '', message];
79
101
  if (bodyText) {
80
- parts.push(bodyText);
81
- parts.push(''); // Empty line before metadata
102
+ parts.push('', '## Details', '', bodyText);
82
103
  }
83
- parts.push(generateMetadata());
104
+ parts.push('', generateMetadata());
84
105
  return parts.join('\n');
85
106
  }
86
107
  /**
@@ -216,7 +237,7 @@ export class FeedbackCommand {
216
237
  async execute(message, options) {
217
238
  // Format title and body once for all code paths
218
239
  const title = formatTitle(message);
219
- const body = formatBody(options?.body);
240
+ const body = formatBody(message, options?.body);
220
241
  // Check if gh CLI is installed
221
242
  if (!isGhInstalled()) {
222
243
  handleFallback(title, body, 'missing');
@@ -1,4 +1,9 @@
1
1
  import { Command } from 'commander';
2
+ import * as fs from 'node:fs';
3
+ /** @internal File-operation seam for transactional failure tests. */
4
+ export declare const schemaInitFileOperations: {
5
+ renameSync: typeof fs.renameSync;
6
+ };
2
7
  /**
3
8
  * Register the schema command and all its subcommands.
4
9
  */