@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.
- package/dist/cli/index.d.ts +17 -0
- package/dist/cli/index.js +60 -9
- package/dist/commands/change.d.ts +24 -0
- package/dist/commands/change.js +223 -0
- package/dist/commands/config.js +14 -3
- package/dist/commands/feedback.js +28 -7
- package/dist/commands/schema.d.ts +5 -0
- package/dist/commands/schema.js +201 -35
- package/dist/commands/show.js +1 -1
- package/dist/commands/workflow/index.d.ts +1 -1
- package/dist/commands/workflow/index.js +1 -1
- package/dist/commands/workflow/status.d.ts +2 -0
- package/dist/commands/workflow/status.js +76 -12
- package/dist/core/archive.js +64 -7
- package/dist/core/artifact-graph/instruction-loader.js +2 -7
- package/dist/core/artifact-graph/outputs.d.ts +4 -0
- package/dist/core/artifact-graph/outputs.js +7 -0
- package/dist/core/artifact-graph/resolver.js +15 -9
- package/dist/core/available-tools.js +16 -1
- package/dist/core/command-generation/adapters/antigravity.d.ts +1 -1
- package/dist/core/command-generation/adapters/antigravity.js +2 -2
- package/dist/core/command-generation/adapters/opencode.d.ts +2 -1
- package/dist/core/command-generation/adapters/opencode.js +13 -2
- package/dist/core/completion-tip.d.ts +15 -0
- package/dist/core/completion-tip.js +133 -0
- package/dist/core/completions/command-registry.js +20 -0
- package/dist/core/completions/factory.d.ts +11 -0
- package/dist/core/completions/generators/fish-generator.d.ts +12 -0
- package/dist/core/completions/generators/fish-generator.js +79 -47
- package/dist/core/completions/installers/bash-installer.d.ts +7 -0
- package/dist/core/completions/installers/bash-installer.js +15 -0
- package/dist/core/completions/installers/fish-installer.d.ts +7 -0
- package/dist/core/completions/installers/fish-installer.js +15 -0
- package/dist/core/completions/installers/powershell-installer.d.ts +7 -0
- package/dist/core/completions/installers/powershell-installer.js +15 -0
- package/dist/core/completions/templates/fish-templates.d.ts +1 -1
- package/dist/core/completions/templates/fish-templates.js +107 -10
- package/dist/core/completions/types.d.ts +4 -0
- package/dist/core/config-prompts.js +19 -10
- package/dist/core/config-schema.d.ts +1 -0
- package/dist/core/config-schema.js +2 -0
- package/dist/core/config.js +7 -1
- package/dist/core/global-config.d.ts +2 -0
- package/dist/core/init.d.ts +5 -0
- package/dist/core/init.js +117 -14
- package/dist/core/legacy-cleanup.js +6 -0
- package/dist/core/migration.js +42 -12
- package/dist/core/parsers/requirement-blocks.d.ts +1 -0
- package/dist/core/parsers/requirement-blocks.js +2 -0
- package/dist/core/profiles.d.ts +1 -1
- package/dist/core/profiles.js +11 -2
- package/dist/core/shared/tool-detection.js +9 -3
- package/dist/core/shared-skill-target.d.ts +13 -0
- package/dist/core/shared-skill-target.js +43 -1
- package/dist/core/specs-apply.js +16 -8
- package/dist/core/templates/workflows/explore.js +79 -73
- package/dist/core/templates/workflows/feedback.js +1 -0
- package/dist/core/templates/workflows/onboard.js +4 -4
- package/dist/core/update.js +39 -26
- package/dist/core/validation/constants.d.ts +3 -0
- package/dist/core/validation/constants.js +12 -0
- package/dist/core/validation/purpose-placeholder.d.ts +45 -0
- package/dist/core/validation/purpose-placeholder.js +117 -0
- package/dist/core/validation/validator.js +16 -1
- package/dist/telemetry/index.js +3 -2
- package/dist/utils/change-metadata.js +17 -13
- package/dist/utils/change-utils.js +5 -0
- package/dist/utils/requirement-diff.d.ts +42 -0
- package/dist/utils/requirement-diff.js +61 -0
- package/package.json +4 -6
- package/schemas/spec-driven/schema.yaml +17 -7
- package/scripts/postinstall.js +0 -83
package/dist/cli/index.d.ts
CHANGED
|
@@ -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).
|
|
109
|
-
//
|
|
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
|
-
|
|
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, {
|
|
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)
|
package/dist/commands/change.js
CHANGED
|
@@ -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
|
/**
|
package/dist/commands/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*/
|