@fission-ai/openspec 1.12.0 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -15
- package/dist/commands/change.js +12 -1
- package/dist/commands/config.d.ts +9 -0
- package/dist/commands/config.js +161 -22
- package/dist/commands/feedback.js +6 -4
- package/dist/commands/schema.js +30 -9
- package/dist/commands/store.js +5 -2
- package/dist/commands/validate.d.ts +7 -0
- package/dist/commands/validate.js +63 -1
- package/dist/commands/workflow/instructions.js +170 -16
- package/dist/commands/workflow/new-change.js +20 -0
- package/dist/commands/workflow/shared.d.ts +8 -0
- package/dist/commands/workflow/shared.js +8 -0
- package/dist/commands/workflow/status.d.ts +5 -1
- package/dist/commands/workflow/status.js +42 -5
- package/dist/core/archive.js +17 -1
- package/dist/core/artifact-graph/schema.d.ts +14 -0
- package/dist/core/artifact-graph/schema.js +52 -0
- package/dist/core/artifact-graph/types.js +12 -1
- package/dist/core/change-status-policy.d.ts +14 -0
- package/dist/core/change-status-policy.js +14 -6
- package/dist/core/command-generation/generator.js +2 -0
- package/dist/core/completion-tip.js +7 -7
- package/dist/core/completions/installers/bash-installer.js +15 -7
- package/dist/core/completions/installers/shell-quote.d.ts +11 -0
- package/dist/core/completions/installers/shell-quote.js +13 -0
- package/dist/core/completions/installers/zsh-installer.js +3 -2
- package/dist/core/config-schema.d.ts +1 -1
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +27 -1
- package/dist/core/global-config.d.ts +30 -2
- package/dist/core/global-config.js +57 -3
- package/dist/core/init.js +33 -3
- package/dist/core/legacy-cleanup.d.ts +4 -0
- package/dist/core/legacy-cleanup.js +180 -12
- package/dist/core/list.js +55 -11
- package/dist/core/migration.js +6 -1
- package/dist/core/onboarding-commands.d.ts +11 -1
- package/dist/core/onboarding-commands.js +26 -0
- package/dist/core/parsers/change-parser.d.ts +12 -1
- package/dist/core/parsers/change-parser.js +85 -87
- package/dist/core/parsers/markdown-parser.js +4 -3
- package/dist/core/parsers/requirement-blocks.d.ts +44 -3
- package/dist/core/parsers/requirement-blocks.js +164 -43
- package/dist/core/parsers/requirement-text.d.ts +13 -2
- package/dist/core/parsers/requirement-text.js +34 -7
- package/dist/core/parsers/spec-structure.js +4 -1
- package/dist/core/project-config.d.ts +9 -1
- package/dist/core/project-config.js +11 -2
- package/dist/core/references.d.ts +20 -0
- package/dist/core/references.js +56 -0
- package/dist/core/shared/skill-content-equivalence.js +5 -1
- package/dist/core/shared/skill-generation.js +36 -8
- package/dist/core/shared/tool-detection.js +37 -4
- package/dist/core/specs-apply.js +260 -18
- package/dist/core/store/git.d.ts +33 -0
- package/dist/core/store/git.js +60 -6
- package/dist/core/store/operations.d.ts +1 -1
- package/dist/core/store/operations.js +32 -10
- package/dist/core/store/registry.d.ts +2 -1
- package/dist/core/store/registry.js +5 -1
- package/dist/core/templates/optional-workflow.d.ts +69 -0
- package/dist/core/templates/optional-workflow.js +140 -0
- package/dist/core/templates/workflows/apply-change.js +18 -3
- package/dist/core/templates/workflows/archive-change.js +50 -15
- package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
- package/dist/core/templates/workflows/continue-change.js +14 -2
- package/dist/core/templates/workflows/explore.js +48 -14
- package/dist/core/templates/workflows/ff-change.js +19 -3
- package/dist/core/templates/workflows/new-change.js +15 -3
- package/dist/core/templates/workflows/onboard.js +67 -37
- package/dist/core/templates/workflows/project-root.d.ts +29 -0
- package/dist/core/templates/workflows/project-root.js +38 -0
- package/dist/core/templates/workflows/propose.js +52 -15
- package/dist/core/templates/workflows/sync-specs.js +40 -1
- package/dist/core/templates/workflows/update-change.js +52 -25
- package/dist/core/templates/workflows/verify-change.js +12 -3
- package/dist/core/update.d.ts +34 -0
- package/dist/core/update.js +109 -11
- package/dist/core/validation/task-checkboxes.d.ts +34 -0
- package/dist/core/validation/task-checkboxes.js +181 -0
- package/dist/core/validation/validator.d.ts +53 -1
- package/dist/core/validation/validator.js +154 -40
- package/dist/core/version-check.d.ts +14 -7
- package/dist/core/version-check.js +63 -17
- package/dist/core/worksets.js +6 -4
- package/dist/prompts/searchable-multi-select.d.ts +7 -0
- package/dist/prompts/searchable-multi-select.js +35 -6
- package/dist/telemetry/config.js +14 -2
- package/dist/telemetry/index.d.ts +4 -3
- package/dist/telemetry/index.js +22 -10
- package/dist/telemetry/opt-out.d.ts +17 -0
- package/dist/telemetry/opt-out.js +27 -0
- package/dist/utils/nested-change.d.ts +35 -0
- package/dist/utils/nested-change.js +186 -0
- package/dist/utils/spec-discovery.d.ts +20 -0
- package/dist/utils/spec-discovery.js +59 -0
- package/dist/utils/task-progress.d.ts +1 -1
- package/dist/utils/task-progress.js +38 -6
- package/package.json +4 -4
- package/schemas/spec-driven/schema.yaml +24 -5
- package/schemas/spec-driven/templates/design.md +2 -0
- package/schemas/spec-driven/templates/proposal.md +2 -0
- package/schemas/spec-driven/templates/spec.md +2 -0
- package/schemas/spec-driven/templates/tasks.md +2 -0
package/README.md
CHANGED
|
@@ -141,7 +141,7 @@ openspec init
|
|
|
141
141
|
|
|
142
142
|
Now talk to your AI:
|
|
143
143
|
|
|
144
|
-
- **Not sure what to build yet?** Start with `/opsx:explore`, a no-stakes thinking partner that reads your code, weighs options, and shapes a plan before
|
|
144
|
+
- **Not sure what to build yet?** Start with `/opsx:explore`, a no-stakes thinking partner that reads your code, weighs options, and shapes a plan before any code gets written. ([Explore guide](docs/explore.md))
|
|
145
145
|
- **Already know what you want?** Go straight to `/opsx:propose <what-you-want-to-build>`.
|
|
146
146
|
|
|
147
147
|
Both are in the default profile. If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:bulk-archive`, `/opsx:onboard`), select it with `openspec config profile` and apply with `openspec update`.
|
|
@@ -224,21 +224,9 @@ openspec update
|
|
|
224
224
|
|
|
225
225
|
## Contributing
|
|
226
226
|
|
|
227
|
-
|
|
227
|
+
Open a discussion (for core design changes) or an issue before you open a PR, and link the issue or discussion from the PR. New features, significant refactors, and architectural changes need an OpenSpec change proposal first.
|
|
228
228
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
When writing proposals, keep the OpenSpec philosophy in mind: we serve a wide variety of users across different coding agents, models, and use cases. Changes should work well for everyone.
|
|
232
|
-
|
|
233
|
-
**AI-generated code is welcome** — as long as it's been tested and verified. PRs containing AI-generated code should mention the coding agent and model used (e.g., "Generated with Claude Code using claude-opus-4-5-20251101").
|
|
234
|
-
|
|
235
|
-
### Development
|
|
236
|
-
|
|
237
|
-
- Install dependencies: `pnpm install`
|
|
238
|
-
- Build: `pnpm run build`
|
|
239
|
-
- Test: `pnpm test`
|
|
240
|
-
- Develop CLI locally: `pnpm run dev` or `pnpm run dev:cli`
|
|
241
|
-
- Conventional commits (one-line): `type(scope): subject`
|
|
229
|
+
→ **[CONTRIBUTING.md](CONTRIBUTING.md)**: the full process, from first issue to merged PR
|
|
242
230
|
|
|
243
231
|
## Other
|
|
244
232
|
|
package/dist/commands/change.js
CHANGED
|
@@ -7,6 +7,7 @@ import { VALIDATION_MESSAGES } from '../core/validation/constants.js';
|
|
|
7
7
|
import { ChangeParser } from '../core/parsers/change-parser.js';
|
|
8
8
|
import { isInteractive } from '../utils/interactive.js';
|
|
9
9
|
import { getActiveChangeIds } from '../utils/item-discovery.js';
|
|
10
|
+
import { describeNestedChange, findNestedChangesIn, } from '../utils/nested-change.js';
|
|
10
11
|
import { getTaskProgressForChange } from '../utils/task-progress.js';
|
|
11
12
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
12
13
|
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
|
@@ -102,6 +103,13 @@ export class ChangeCommand {
|
|
|
102
103
|
.then((stats) => stats.isDirectory())
|
|
103
104
|
.catch(() => false);
|
|
104
105
|
if (isChangeDirectory) {
|
|
106
|
+
// A folder holding nested change directories has no proposal of its
|
|
107
|
+
// own and never will; pointing at `status --change` would send the
|
|
108
|
+
// user down a second dead end (#1846).
|
|
109
|
+
const nested = await findNestedChangesIn(changesPath, changeName);
|
|
110
|
+
if (nested) {
|
|
111
|
+
throw new Error(describeNestedChange(nested));
|
|
112
|
+
}
|
|
105
113
|
throw new Error(`Change "${changeName}" has no proposal.md yet. ` +
|
|
106
114
|
`Run "openspec status --change ${changeName}" to see which artifact comes next.`);
|
|
107
115
|
}
|
|
@@ -496,7 +504,10 @@ export class ChangeCommand {
|
|
|
496
504
|
}
|
|
497
505
|
extractTitle(content, changeName) {
|
|
498
506
|
const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/im);
|
|
499
|
-
|
|
507
|
+
const title = match?.[1].trim();
|
|
508
|
+
// The packaged template opens every proposal with a bare `# Proposal`,
|
|
509
|
+
// which names the document rather than the change.
|
|
510
|
+
return title && title.toLowerCase() !== 'proposal' ? title : changeName;
|
|
500
511
|
}
|
|
501
512
|
printNextSteps(issues = []) {
|
|
502
513
|
const bullets = [];
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { GlobalConfig } from '../core/global-config.js';
|
|
3
3
|
import type { Profile, Delivery } from '../core/global-config.js';
|
|
4
|
+
/**
|
|
5
|
+
* Splits an EDITOR or VISUAL value into a program and its arguments without
|
|
6
|
+
* running a shell, so `;`, `|`, `$VAR`, `~` and backticks are plain characters.
|
|
7
|
+
* Double quotes group words. On POSIX, single quotes group words too and a
|
|
8
|
+
* backslash escapes the next character (inside double quotes only `"` and `\`).
|
|
9
|
+
* On Windows a backslash is a path separator and a single quote is a plain
|
|
10
|
+
* character. Returns null when a quote is left open.
|
|
11
|
+
*/
|
|
12
|
+
export declare function splitEditorCommand(value: string, platform?: NodeJS.Platform): string[] | null;
|
|
4
13
|
interface ProfileState {
|
|
5
14
|
profile: Profile;
|
|
6
15
|
delivery: Delivery;
|
package/dist/commands/config.js
CHANGED
|
@@ -1,13 +1,148 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import * as fs from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
|
-
import { getGlobalConfigPath, getGlobalConfig, saveGlobalConfig, } from '../core/global-config.js';
|
|
4
|
+
import { getGlobalConfigPath, getGlobalConfig, isConfigRootObject, isGlobalConfigUnreadable, saveGlobalConfig, } from '../core/global-config.js';
|
|
5
5
|
import { getNestedValue, setNestedValue, deleteNestedValue, coerceValue, formatValueYaml, validateConfigKeyPath, hasUnsafeKeySegment, validateConfig, DEFAULT_CONFIG, } from '../core/config-schema.js';
|
|
6
6
|
import { CORE_WORKFLOWS, ALL_WORKFLOWS, getProfileWorkflows } from '../core/profiles.js';
|
|
7
7
|
import { OPENSPEC_DIR_NAME } from '../core/config.js';
|
|
8
8
|
import { hasProjectConfigDrift } from '../core/profile-sync-drift.js';
|
|
9
9
|
import { UpdateCommand } from '../core/update.js';
|
|
10
10
|
import { asErrorMessage, isPromptCancellationError } from './shared-output.js';
|
|
11
|
+
// cross-spawn finds `.cmd` shims such as `code.cmd` on Windows and escapes each
|
|
12
|
+
// argument for cmd.exe; elsewhere it is plain spawn. Loaded lazily so other
|
|
13
|
+
// commands skip its module graph.
|
|
14
|
+
let cachedSpawn;
|
|
15
|
+
function loadSpawn() {
|
|
16
|
+
if (cachedSpawn === undefined) {
|
|
17
|
+
cachedSpawn = createRequire(import.meta.url)('cross-spawn');
|
|
18
|
+
}
|
|
19
|
+
return cachedSpawn;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Splits an EDITOR or VISUAL value into a program and its arguments without
|
|
23
|
+
* running a shell, so `;`, `|`, `$VAR`, `~` and backticks are plain characters.
|
|
24
|
+
* Double quotes group words. On POSIX, single quotes group words too and a
|
|
25
|
+
* backslash escapes the next character (inside double quotes only `"` and `\`).
|
|
26
|
+
* On Windows a backslash is a path separator and a single quote is a plain
|
|
27
|
+
* character. Returns null when a quote is left open.
|
|
28
|
+
*/
|
|
29
|
+
export function splitEditorCommand(value, platform = process.platform) {
|
|
30
|
+
const posix = platform !== 'win32';
|
|
31
|
+
const words = [];
|
|
32
|
+
let word = '';
|
|
33
|
+
let inWord = false;
|
|
34
|
+
let quote = null;
|
|
35
|
+
for (let i = 0; i < value.length; i++) {
|
|
36
|
+
const ch = value[i];
|
|
37
|
+
if (quote === "'") {
|
|
38
|
+
if (ch === "'")
|
|
39
|
+
quote = null;
|
|
40
|
+
else
|
|
41
|
+
word += ch;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (posix && ch === '\\' && i + 1 < value.length) {
|
|
45
|
+
const next = value[i + 1];
|
|
46
|
+
if (quote === '"' && next !== '"' && next !== '\\') {
|
|
47
|
+
word += ch;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
word += next;
|
|
51
|
+
i++;
|
|
52
|
+
}
|
|
53
|
+
inWord = true;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (quote === '"') {
|
|
57
|
+
if (ch === '"')
|
|
58
|
+
quote = null;
|
|
59
|
+
else
|
|
60
|
+
word += ch;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (ch === '"' || (posix && ch === "'")) {
|
|
64
|
+
quote = ch;
|
|
65
|
+
inWord = true;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (/\s/.test(ch)) {
|
|
69
|
+
if (inWord)
|
|
70
|
+
words.push(word);
|
|
71
|
+
word = '';
|
|
72
|
+
inWord = false;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
word += ch;
|
|
76
|
+
inWord = true;
|
|
77
|
+
}
|
|
78
|
+
if (quote)
|
|
79
|
+
return null;
|
|
80
|
+
if (inWord)
|
|
81
|
+
words.push(word);
|
|
82
|
+
return words;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Starts the user's editor on `filePath`, never through a shell.
|
|
86
|
+
*
|
|
87
|
+
* EDITOR and VISUAL hold a command line, not a program name: `code --wait`
|
|
88
|
+
* and `"/path with spaces/subl" -w` are both ordinary values, so the value is
|
|
89
|
+
* split into words and the file path is appended as its own argument. A value
|
|
90
|
+
* that is itself the absolute path of an existing file is run as-is, so an
|
|
91
|
+
* unquoted editor path with spaces keeps working.
|
|
92
|
+
*/
|
|
93
|
+
function spawnEditor(editor, filePath) {
|
|
94
|
+
const words = path.isAbsolute(editor) && fs.existsSync(editor) ? [editor] : splitEditorCommand(editor);
|
|
95
|
+
if (words === null) {
|
|
96
|
+
throw new Error('the value has an unterminated quote');
|
|
97
|
+
}
|
|
98
|
+
if (words.length === 0) {
|
|
99
|
+
throw new Error('the value is blank');
|
|
100
|
+
}
|
|
101
|
+
const [program, ...args] = words;
|
|
102
|
+
return loadSpawn()(program, [...args, filePath], { stdio: 'inherit', shell: false });
|
|
103
|
+
}
|
|
104
|
+
/** Runs the editor on `filePath` and resolves once it has closed or failed to start. */
|
|
105
|
+
function runEditor(editor, filePath) {
|
|
106
|
+
return new Promise((resolve) => {
|
|
107
|
+
try {
|
|
108
|
+
const child = spawnEditor(editor, filePath);
|
|
109
|
+
child.once('error', (error) => resolve({ error }));
|
|
110
|
+
child.once('close', (code, signal) => resolve({ code, signal }));
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
resolve({ error: error instanceof Error ? error : new Error(String(error)) });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function reportEditorFailure(editor, outcome) {
|
|
118
|
+
if ('error' in outcome) {
|
|
119
|
+
console.error(`Error: Could not start editor "${editor}": ${outcome.error.message}`);
|
|
120
|
+
}
|
|
121
|
+
else if (outcome.signal) {
|
|
122
|
+
console.error(`Error: Editor "${editor}" was terminated by ${outcome.signal}`);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
console.error(`Error: Editor "${editor}" exited with code ${outcome.code}`);
|
|
126
|
+
}
|
|
127
|
+
// Only a missing program earns the hint: EACCES or EPERM means it exists.
|
|
128
|
+
if ('error' in outcome && outcome.error.code === 'ENOENT') {
|
|
129
|
+
console.error('Set EDITOR or VISUAL to an installed editor command, for example: export EDITOR="code --wait"');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* A config file that exists but cannot be parsed is still the user's file:
|
|
134
|
+
* getGlobalConfig() reads it as defaults, and saving those back would erase
|
|
135
|
+
* every setting in it. Reports the fix instead, and returns true when it did.
|
|
136
|
+
*/
|
|
137
|
+
function refuseUnreadableConfig() {
|
|
138
|
+
if (!isGlobalConfigUnreadable()) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
console.error(`Error: ${getGlobalConfigPath()} could not be parsed, so it was left unchanged.`);
|
|
142
|
+
console.error('Fix it with "openspec config edit", or reset it with "openspec config reset --all".');
|
|
143
|
+
process.exitCode = 1;
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
11
146
|
export const WORKFLOW_PROMPT_META = {
|
|
12
147
|
propose: {
|
|
13
148
|
name: 'Propose change',
|
|
@@ -187,7 +322,12 @@ export function registerConfigCommand(program) {
|
|
|
187
322
|
let rawConfig = {};
|
|
188
323
|
try {
|
|
189
324
|
if (fs.existsSync(configPath)) {
|
|
190
|
-
|
|
325
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
326
|
+
// A non-object root holds no explicit settings, and reading a key
|
|
327
|
+
// off `null` would crash this read-only command.
|
|
328
|
+
if (isConfigRootObject(parsed)) {
|
|
329
|
+
rawConfig = parsed;
|
|
330
|
+
}
|
|
191
331
|
}
|
|
192
332
|
}
|
|
193
333
|
catch {
|
|
@@ -250,6 +390,9 @@ export function registerConfigCommand(program) {
|
|
|
250
390
|
process.exitCode = 1;
|
|
251
391
|
return;
|
|
252
392
|
}
|
|
393
|
+
if (refuseUnreadableConfig()) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
253
396
|
const config = getGlobalConfig();
|
|
254
397
|
const coercedValue = coerceValue(value, options.string || false);
|
|
255
398
|
// Create a copy to validate before saving
|
|
@@ -273,6 +416,9 @@ export function registerConfigCommand(program) {
|
|
|
273
416
|
.command('unset <key>')
|
|
274
417
|
.description('Remove a key (revert to default)')
|
|
275
418
|
.action((key) => {
|
|
419
|
+
if (refuseUnreadableConfig()) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
276
422
|
const config = getGlobalConfig();
|
|
277
423
|
const existed = deleteNestedValue(config, key);
|
|
278
424
|
if (existed) {
|
|
@@ -318,7 +464,8 @@ export function registerConfigCommand(program) {
|
|
|
318
464
|
return;
|
|
319
465
|
}
|
|
320
466
|
}
|
|
321
|
-
|
|
467
|
+
// A reset is the one write meant to replace a file that cannot be parsed.
|
|
468
|
+
saveGlobalConfig({ ...DEFAULT_CONFIG }, { replaceUnreadable: true });
|
|
322
469
|
console.log('Configuration reset to defaults');
|
|
323
470
|
});
|
|
324
471
|
// config edit
|
|
@@ -339,24 +486,13 @@ export function registerConfigCommand(program) {
|
|
|
339
486
|
if (!fs.existsSync(configPath)) {
|
|
340
487
|
saveGlobalConfig({ ...DEFAULT_CONFIG });
|
|
341
488
|
}
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
}
|
|
349
|
-
await new Promise((resolve, reject) => {
|
|
350
|
-
child.on('close', (code) => {
|
|
351
|
-
if (code === 0) {
|
|
352
|
-
resolve();
|
|
353
|
-
}
|
|
354
|
-
else {
|
|
355
|
-
reject(new Error(`Editor exited with code ${code}`));
|
|
356
|
-
}
|
|
357
|
-
});
|
|
358
|
-
child.on('error', reject);
|
|
359
|
-
});
|
|
489
|
+
// Wait for the editor to close; a failure is reported, never thrown.
|
|
490
|
+
const outcome = await runEditor(editor, configPath);
|
|
491
|
+
if ('error' in outcome || outcome.code !== 0) {
|
|
492
|
+
reportEditorFailure(editor, outcome);
|
|
493
|
+
process.exitCode = 1;
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
360
496
|
try {
|
|
361
497
|
const rawConfig = fs.readFileSync(configPath, 'utf-8');
|
|
362
498
|
const parsedConfig = JSON.parse(rawConfig);
|
|
@@ -385,6 +521,9 @@ export function registerConfigCommand(program) {
|
|
|
385
521
|
.command('profile [preset]')
|
|
386
522
|
.description('Configure workflow profile (interactive picker or preset shortcut)')
|
|
387
523
|
.action(async (preset) => {
|
|
524
|
+
if (refuseUnreadableConfig()) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
388
527
|
// Preset shortcut: `openspec config profile core`
|
|
389
528
|
if (preset === 'core') {
|
|
390
529
|
const config = getGlobalConfig();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
2
|
import { createRequire } from 'module';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
const require = createRequire(import.meta.url);
|
|
@@ -10,8 +10,10 @@ const TITLE_PREFIX = 'Feedback: ';
|
|
|
10
10
|
*/
|
|
11
11
|
function isGhInstalled() {
|
|
12
12
|
try {
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// execFileSync, not execSync: no shell is needed to look a binary up, and
|
|
14
|
+
// spawning one next to free-form issue text is the shape a future refactor
|
|
15
|
+
// most easily turns into command injection.
|
|
16
|
+
execFileSync(process.platform === 'win32' ? 'where' : 'which', ['gh'], { stdio: 'pipe' });
|
|
15
17
|
return true;
|
|
16
18
|
}
|
|
17
19
|
catch {
|
|
@@ -23,7 +25,7 @@ function isGhInstalled() {
|
|
|
23
25
|
*/
|
|
24
26
|
function isGhAuthenticated() {
|
|
25
27
|
try {
|
|
26
|
-
|
|
28
|
+
execFileSync('gh', ['auth', 'status'], { stdio: 'pipe' });
|
|
27
29
|
return true;
|
|
28
30
|
}
|
|
29
31
|
catch {
|
package/dist/commands/schema.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto';
|
|
|
4
4
|
import ora from 'ora';
|
|
5
5
|
import { stringify as stringifyYaml, parseDocument, isMap } from 'yaml';
|
|
6
6
|
import { getSchemaDir, getProjectSchemasDir, getUserSchemasDir, getPackageSchemasDir, isSchemaDir, listSchemas, } from '../core/artifact-graph/resolver.js';
|
|
7
|
-
import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js';
|
|
7
|
+
import { findApplyTracksWarning, parseSchema, SchemaValidationError, } from '../core/artifact-graph/schema.js';
|
|
8
8
|
import { resolveConfigFilePath } from '../core/project-config.js';
|
|
9
9
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
10
10
|
/**
|
|
@@ -158,12 +158,18 @@ function validateSchema(schemaDir, verbose = false) {
|
|
|
158
158
|
});
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
|
-
// Dependency graph validation is already done by parseSchema
|
|
162
|
-
//
|
|
161
|
+
// Dependency graph validation is already done by parseSchema (it throws on
|
|
162
|
+
// cycles, invalid references, and an unknown apply.requires id)
|
|
163
163
|
if (verbose) {
|
|
164
164
|
console.log(' Dependency graph validation passed (via parseSchema)');
|
|
165
165
|
}
|
|
166
|
-
|
|
166
|
+
// An apply.tracks value that matches no generates value exactly still loads
|
|
167
|
+
// (apply reads the path as written), so it is a warning, not an error.
|
|
168
|
+
const tracksWarning = findApplyTracksWarning(schema);
|
|
169
|
+
if (tracksWarning) {
|
|
170
|
+
issues.push({ level: 'warning', path: 'apply.tracks', message: tracksWarning });
|
|
171
|
+
}
|
|
172
|
+
return { valid: !issues.some((issue) => issue.level === 'error'), issues };
|
|
167
173
|
}
|
|
168
174
|
/**
|
|
169
175
|
* Validate schema name format (kebab-case).
|
|
@@ -590,6 +596,9 @@ export function registerSchemaCommand(program) {
|
|
|
590
596
|
else {
|
|
591
597
|
if (result.valid) {
|
|
592
598
|
console.log(`✓ Schema '${name}' is valid`);
|
|
599
|
+
for (const issue of result.issues) {
|
|
600
|
+
console.log(` ${issue.level}: ${issue.message}`);
|
|
601
|
+
}
|
|
593
602
|
}
|
|
594
603
|
else {
|
|
595
604
|
console.log(`✗ Schema '${name}' has errors:`);
|
|
@@ -1201,11 +1210,17 @@ export function registerSchemaCommand(program) {
|
|
|
1201
1210
|
}
|
|
1202
1211
|
/**
|
|
1203
1212
|
* Create default template content for an artifact.
|
|
1213
|
+
*
|
|
1214
|
+
* Every template opens with a top-level heading so the artifact it produces is
|
|
1215
|
+
* a well-formed markdown document rather than a file whose first line is a
|
|
1216
|
+
* section header (markdownlint MD041, #1138).
|
|
1204
1217
|
*/
|
|
1205
1218
|
function createDefaultTemplate(artifactId) {
|
|
1206
1219
|
switch (artifactId) {
|
|
1207
1220
|
case 'proposal':
|
|
1208
|
-
return
|
|
1221
|
+
return `# Proposal
|
|
1222
|
+
|
|
1223
|
+
## Why
|
|
1209
1224
|
|
|
1210
1225
|
<!-- Describe the motivation for this change -->
|
|
1211
1226
|
|
|
@@ -1226,7 +1241,9 @@ function createDefaultTemplate(artifactId) {
|
|
|
1226
1241
|
<!-- Describe the impact on existing functionality -->
|
|
1227
1242
|
`;
|
|
1228
1243
|
case 'specs':
|
|
1229
|
-
return
|
|
1244
|
+
return `# Spec Delta
|
|
1245
|
+
|
|
1246
|
+
## ADDED Requirements
|
|
1230
1247
|
|
|
1231
1248
|
### Requirement: Example requirement
|
|
1232
1249
|
|
|
@@ -1237,7 +1254,9 @@ Description of the requirement.
|
|
|
1237
1254
|
- **THEN** some outcome
|
|
1238
1255
|
`;
|
|
1239
1256
|
case 'design':
|
|
1240
|
-
return
|
|
1257
|
+
return `# Design
|
|
1258
|
+
|
|
1259
|
+
## Context
|
|
1241
1260
|
|
|
1242
1261
|
<!-- Background and context -->
|
|
1243
1262
|
|
|
@@ -1263,14 +1282,16 @@ Description and rationale.
|
|
|
1263
1282
|
<!-- List risks and trade-offs -->
|
|
1264
1283
|
`;
|
|
1265
1284
|
case 'tasks':
|
|
1266
|
-
return
|
|
1285
|
+
return `# Tasks
|
|
1286
|
+
|
|
1287
|
+
## Implementation Tasks
|
|
1267
1288
|
|
|
1268
1289
|
- [ ] Task 1
|
|
1269
1290
|
- [ ] Task 2
|
|
1270
1291
|
- [ ] Task 3
|
|
1271
1292
|
`;
|
|
1272
1293
|
default:
|
|
1273
|
-
return
|
|
1294
|
+
return `# ${artifactId}
|
|
1274
1295
|
|
|
1275
1296
|
<!-- Add content here -->
|
|
1276
1297
|
`;
|
package/dist/commands/store.js
CHANGED
|
@@ -143,8 +143,11 @@ async function resolveSetupInput(id, options) {
|
|
|
143
143
|
...(options.remote !== undefined ? { remote: options.remote } : {}),
|
|
144
144
|
};
|
|
145
145
|
}
|
|
146
|
-
async function prepareSetupInput(input,
|
|
147
|
-
return prepareStoreSetup(
|
|
146
|
+
async function prepareSetupInput(input, options) {
|
|
147
|
+
return prepareStoreSetup({
|
|
148
|
+
...input,
|
|
149
|
+
...(options.initGit !== undefined ? { initGit: options.initGit } : {}),
|
|
150
|
+
});
|
|
148
151
|
}
|
|
149
152
|
async function confirmSetup(prepared, initGit) {
|
|
150
153
|
const { confirm } = await import('@inquirer/prompts');
|
|
@@ -81,6 +81,13 @@ export declare class ValidateCommand {
|
|
|
81
81
|
private runInteractiveSelector;
|
|
82
82
|
private printNonInteractiveHint;
|
|
83
83
|
private validateDirectItem;
|
|
84
|
+
/**
|
|
85
|
+
* A namespace folder wrapping nested change directories has no deltas of its
|
|
86
|
+
* own and never will. The usual "add a delta spec" error points the author at
|
|
87
|
+
* a directory that is not the change, so the nesting is reported instead
|
|
88
|
+
* (#1846). Returns undefined for every ordinary change.
|
|
89
|
+
*/
|
|
90
|
+
private nestedChangeReport;
|
|
84
91
|
private validateByType;
|
|
85
92
|
private printReport;
|
|
86
93
|
private printNextSteps;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ora from 'ora';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import { describeNestedChange, findNestedChangesIn, NESTED_CHANGE_ISSUE_MARKER, } from '../utils/nested-change.js';
|
|
3
4
|
import { Validator } from '../core/validation/validator.js';
|
|
4
5
|
import { VALIDATION_MESSAGES } from '../core/validation/constants.js';
|
|
5
6
|
import { resolveRootForCommand, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from '../core/root-selection.js';
|
|
@@ -10,6 +11,7 @@ import { nearestMatches } from '../utils/match.js';
|
|
|
10
11
|
import { promises as fs } from 'fs';
|
|
11
12
|
import { getTaskProgressDetailForChange } from '../utils/task-progress.js';
|
|
12
13
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
14
|
+
import { folderStyleNameProblem } from '../core/id.js';
|
|
13
15
|
/** Findings are a distinct report, not a partial full-v1 items collection. */
|
|
14
16
|
export function projectValidationFindings(full, scope) {
|
|
15
17
|
const itemFindings = full.items.filter(item => item.issues.length > 0);
|
|
@@ -204,11 +206,54 @@ export class ValidateCommand {
|
|
|
204
206
|
}
|
|
205
207
|
await this.validateByType(root, type, itemName, opts);
|
|
206
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* A namespace folder wrapping nested change directories has no deltas of its
|
|
211
|
+
* own and never will. The usual "add a delta spec" error points the author at
|
|
212
|
+
* a directory that is not the change, so the nesting is reported instead
|
|
213
|
+
* (#1846). Returns undefined for every ordinary change.
|
|
214
|
+
*/
|
|
215
|
+
async nestedChangeReport(root, id) {
|
|
216
|
+
const nested = await findNestedChangesIn(root.changesDir, id);
|
|
217
|
+
if (!nested)
|
|
218
|
+
return undefined;
|
|
219
|
+
return {
|
|
220
|
+
valid: false,
|
|
221
|
+
issues: [{ level: 'ERROR', path: 'file', message: describeNestedChange(nested) }],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
207
224
|
async validateByType(root, type, id, opts) {
|
|
225
|
+
// `--type` skips the membership check above, so the name still has to be
|
|
226
|
+
// guarded before it is joined onto a directory. `show` already rejects a
|
|
227
|
+
// traversing id.
|
|
228
|
+
//
|
|
229
|
+
// Spec ids are nested (`specs/<area>/<capability>/spec.md`, #1353), so the
|
|
230
|
+
// guard runs per segment - rejecting the whole id for containing a `/`
|
|
231
|
+
// would break every nested capability, including the hint that
|
|
232
|
+
// `validate --specs` prints. Change names are flat, so they keep the
|
|
233
|
+
// whole-value check.
|
|
234
|
+
const nameProblem = type === 'change'
|
|
235
|
+
? folderStyleNameProblem(id, 'Change name')
|
|
236
|
+
: (id.split('/').map((segment) => folderStyleNameProblem(segment, 'Spec id')).find(Boolean) ?? null);
|
|
237
|
+
if (nameProblem) {
|
|
238
|
+
if (opts.json) {
|
|
239
|
+
console.log(JSON.stringify({ status: [{ severity: 'error', code: 'invalid_item', message: nameProblem }] }, null, 2));
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
console.error(nameProblem);
|
|
243
|
+
}
|
|
244
|
+
process.exitCode = 1;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
208
247
|
const validator = new Validator(opts.strict);
|
|
209
248
|
if (type === 'change') {
|
|
210
249
|
const changeDir = path.join(root.changesDir, id);
|
|
211
250
|
const start = Date.now();
|
|
251
|
+
const nestedReport = await this.nestedChangeReport(root, id);
|
|
252
|
+
if (nestedReport) {
|
|
253
|
+
this.printReport('change', id, nestedReport, Date.now() - start, opts.json, root);
|
|
254
|
+
process.exitCode = 1;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
212
257
|
const report = await validator.validateChangeDeltaSpecs(changeDir, {
|
|
213
258
|
mainSpecsDir: root.specsDir,
|
|
214
259
|
projectRoot: root.path,
|
|
@@ -254,7 +299,14 @@ export class ValidateCommand {
|
|
|
254
299
|
// skip_specs, which must not trigger this.
|
|
255
300
|
const conflictIssue = issues.some(i => i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT));
|
|
256
301
|
const invalidMarkerIssue = issues.some(i => i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA));
|
|
257
|
-
|
|
302
|
+
// A namespace folder has no deltas to author, so the delta-authoring
|
|
303
|
+
// bullets below would point at a directory that is not the change (#1846).
|
|
304
|
+
const nestedIssue = issues.some(i => i.message.includes(NESTED_CHANGE_ISSUE_MARKER));
|
|
305
|
+
if (type === 'change' && nestedIssue) {
|
|
306
|
+
bullets.push('- Move each nested change directly under openspec/changes/, folding the namespace into its name');
|
|
307
|
+
bullets.push('- Only specs may be nested by domain; change directories are always flat');
|
|
308
|
+
}
|
|
309
|
+
else if (type === 'change' && conflictIssue) {
|
|
258
310
|
bullets.push('- This change declares skip_specs (no spec deltas): delete the files under specs/, or remove skip_specs from .openspec.yaml if requirements do change');
|
|
259
311
|
bullets.push('- skip_specs is only honored when .openspec.yaml is valid change metadata (schema: <name> naming a known schema is required)');
|
|
260
312
|
}
|
|
@@ -318,6 +370,16 @@ export class ValidateCommand {
|
|
|
318
370
|
queue.push(async () => {
|
|
319
371
|
const start = Date.now();
|
|
320
372
|
const changeDir = path.join(root.changesDir, id);
|
|
373
|
+
const nestedReport = await this.nestedChangeReport(root, id);
|
|
374
|
+
if (nestedReport) {
|
|
375
|
+
return {
|
|
376
|
+
id,
|
|
377
|
+
type: 'change',
|
|
378
|
+
valid: false,
|
|
379
|
+
issues: nestedReport.issues,
|
|
380
|
+
durationMs: Date.now() - start,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
321
383
|
const report = await validator.validateChangeDeltaSpecs(changeDir, {
|
|
322
384
|
mainSpecsDir: root.specsDir,
|
|
323
385
|
projectRoot: root.path,
|