@fission-ai/openspec 1.8.0 → 1.9.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 +15 -0
- package/dist/cli/index.js +33 -4
- package/dist/commands/change.js +1 -0
- package/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.js +5 -1
- package/dist/commands/schema.js +193 -28
- package/dist/commands/validate.d.ts +23 -0
- package/dist/commands/validate.js +148 -4
- package/dist/commands/workflow/schemas.d.ts +2 -0
- package/dist/commands/workflow/schemas.js +9 -2
- package/dist/core/archive.js +10 -3
- package/dist/core/artifact-graph/resolver.d.ts +0 -12
- package/dist/core/artifact-graph/resolver.js +14 -0
- package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
- package/dist/core/command-generation/adapters/command-code.js +34 -0
- package/dist/core/command-generation/adapters/index.d.ts +1 -0
- package/dist/core/command-generation/adapters/index.js +1 -0
- package/dist/core/command-generation/registry.js +2 -0
- package/dist/core/completions/command-registry.js +5 -0
- package/dist/core/config.d.ts +1 -0
- package/dist/core/config.js +16 -15
- package/dist/core/init.js +19 -7
- package/dist/core/legacy-cleanup.d.ts +10 -0
- package/dist/core/legacy-cleanup.js +48 -26
- package/dist/core/parsers/requirement-blocks.js +41 -9
- package/dist/core/parsers/requirement-text.d.ts +8 -0
- package/dist/core/parsers/requirement-text.js +4 -3
- package/dist/core/root-selection.d.ts +1 -1
- package/dist/core/shared-skill-target.d.ts +15 -0
- package/dist/core/shared-skill-target.js +30 -0
- package/dist/core/specs-apply.js +11 -4
- package/dist/core/templates/workflows/apply-change.js +3 -0
- package/dist/core/templates/workflows/propose.js +2 -2
- package/dist/core/templates/workflows/store-selection.d.ts +1 -1
- package/dist/core/templates/workflows/store-selection.js +1 -1
- package/dist/core/templates/workflows/update-change.js +4 -4
- package/dist/core/update.js +34 -7
- package/dist/core/validation/task-numbering.d.ts +16 -0
- package/dist/core/validation/task-numbering.js +57 -0
- package/dist/core/validation/validator.d.ts +5 -2
- package/dist/core/validation/validator.js +57 -3
- package/dist/telemetry/index.d.ts +3 -1
- package/dist/telemetry/index.js +7 -1
- package/dist/utils/interactive.d.ts +30 -1
- package/dist/utils/interactive.js +105 -2
- package/dist/utils/task-progress.d.ts +34 -1
- package/dist/utils/task-progress.js +52 -28
- package/package.json +1 -1
package/dist/core/archive.js
CHANGED
|
@@ -10,7 +10,7 @@ import { emitStoreRootBanner, isRootSelectionError, resolveOpenSpecRoot, toRootO
|
|
|
10
10
|
import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, retireSpec, finalizeRetiredSpec, } from './specs-apply.js';
|
|
11
11
|
import { discoverSpecFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
|
|
12
12
|
import { METADATA_FILENAME, readRetireCapabilitiesMarker, readSkipSpecsMarker } from '../utils/change-metadata.js';
|
|
13
|
-
import { isNonInteractivePromptError } from '../utils/interactive.js';
|
|
13
|
+
import { confirmPrompt, isNonInteractivePromptError } from '../utils/interactive.js';
|
|
14
14
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
15
15
|
import { folderStyleNameProblem } from './id.js';
|
|
16
16
|
function isMissingPathError(error) {
|
|
@@ -202,9 +202,8 @@ function rerunCommand(root, changeName, options) {
|
|
|
202
202
|
* `User force closed the prompt` (#1479).
|
|
203
203
|
*/
|
|
204
204
|
async function confirmOrBlock(prompt, blocked) {
|
|
205
|
-
const { confirm } = await import('@inquirer/prompts');
|
|
206
205
|
try {
|
|
207
|
-
return await
|
|
206
|
+
return await confirmPrompt(prompt);
|
|
208
207
|
}
|
|
209
208
|
catch (error) {
|
|
210
209
|
if (isNonInteractivePromptError(error)) {
|
|
@@ -1603,6 +1602,14 @@ export class ArchiveCommand {
|
|
|
1603
1602
|
console.log('No active changes found.');
|
|
1604
1603
|
return null;
|
|
1605
1604
|
}
|
|
1605
|
+
// A picker needs a real terminal, and @inquirer's `select` writes ANSI
|
|
1606
|
+
// cursor escapes to stdout even when it is redirected — the same #1526
|
|
1607
|
+
// mechanism the confirm prompts were fixed for. When either stream is not a
|
|
1608
|
+
// TTY, refuse up front with the guidance the caught ExitPromptError would
|
|
1609
|
+
// give, rather than render an escape-spewing menu into a pipe or file.
|
|
1610
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1611
|
+
throw new ArchiveBlockedError('archive_change_name_required', 'A change name is required: no terminal is available to choose one from a list.', withStoreFlag(root, `openspec archive <change-name> ${rerunFlags(options).join(' ')}`));
|
|
1612
|
+
}
|
|
1606
1613
|
// Build choices with progress inline to avoid duplicate lists
|
|
1607
1614
|
let choices = changeDirs.map(name => ({ name, value: name }));
|
|
1608
1615
|
try {
|
|
@@ -23,18 +23,6 @@ export declare function getUserSchemasDir(): string;
|
|
|
23
23
|
* @returns The path to the project's schemas directory
|
|
24
24
|
*/
|
|
25
25
|
export declare function getProjectSchemasDir(projectRoot: string): string;
|
|
26
|
-
/**
|
|
27
|
-
* Determines whether a directory entry represents a schema directory candidate.
|
|
28
|
-
*
|
|
29
|
-
* Returns true for real directories and for symlinks whose target is a
|
|
30
|
-
* directory. `fs.Dirent.isDirectory()` reports the raw entry type, so a symlink
|
|
31
|
-
* (even one pointing at a directory) has `isDirectory() === false`; we
|
|
32
|
-
* dereference such entries via `fs.statSync` to admit symlinked schema dirs
|
|
33
|
-
* while still rejecting symlinks-to-files and broken/dangling symlinks.
|
|
34
|
-
*
|
|
35
|
-
* @param parentDir - The directory containing the entry
|
|
36
|
-
* @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })`
|
|
37
|
-
*/
|
|
38
26
|
export declare function isSchemaDir(parentDir: string, entry: fs.Dirent): boolean;
|
|
39
27
|
/**
|
|
40
28
|
* Resolves a schema name to its directory path.
|
|
@@ -52,7 +52,21 @@ export function getProjectSchemasDir(projectRoot) {
|
|
|
52
52
|
* @param parentDir - The directory containing the entry
|
|
53
53
|
* @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })`
|
|
54
54
|
*/
|
|
55
|
+
/**
|
|
56
|
+
* Directories `schema fork` creates transiently while swapping a fork into
|
|
57
|
+
* place: a staging copy (`.fork-staging-<rand>`, created via mkdtemp) and a
|
|
58
|
+
* backup of the previous destination (`<name>.fork-backup-<pid>-<ts>`). Either
|
|
59
|
+
* can briefly coexist with real schemas in the schemas dir, so discovery must
|
|
60
|
+
* never surface them. Real schema names are kebab-case (no dots), so excluding
|
|
61
|
+
* these dot-bearing temp names can never hide a legitimate schema.
|
|
62
|
+
*/
|
|
63
|
+
function isOwnedForkTempDir(name) {
|
|
64
|
+
return name.startsWith('.fork-staging-') || name.includes('.fork-backup-');
|
|
65
|
+
}
|
|
55
66
|
export function isSchemaDir(parentDir, entry) {
|
|
67
|
+
if (isOwnedForkTempDir(entry.name)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
56
70
|
if (entry.isDirectory()) {
|
|
57
71
|
return true;
|
|
58
72
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Code Command Adapter
|
|
3
|
+
*
|
|
4
|
+
* Command Code reads custom slash commands from `.commandcode/commands/`. The
|
|
5
|
+
* command name is the markdown filename without its `.md` extension, so
|
|
6
|
+
* `opsx-<id>.md` registers `/opsx-<id>` — the same flat naming Cursor and
|
|
7
|
+
* OpenCode use. See https://commandcode.ai/docs/reference/slash-commands.
|
|
8
|
+
*/
|
|
9
|
+
import type { ToolCommandAdapter } from '../types.js';
|
|
10
|
+
/**
|
|
11
|
+
* Command Code adapter for command generation.
|
|
12
|
+
* File path: .commandcode/commands/opsx-<id>.md
|
|
13
|
+
* Format: plain Markdown with $ARGUMENTS injected after the input contract
|
|
14
|
+
*
|
|
15
|
+
* Command Code executes the full trimmed file body and substitutes invocation
|
|
16
|
+
* arguments only where the body includes one of its argument placeholders.
|
|
17
|
+
*/
|
|
18
|
+
export declare const commandCodeAdapter: ToolCommandAdapter;
|
|
19
|
+
//# sourceMappingURL=command-code.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Code Command Adapter
|
|
3
|
+
*
|
|
4
|
+
* Command Code reads custom slash commands from `.commandcode/commands/`. The
|
|
5
|
+
* command name is the markdown filename without its `.md` extension, so
|
|
6
|
+
* `opsx-<id>.md` registers `/opsx-<id>` — the same flat naming Cursor and
|
|
7
|
+
* OpenCode use. See https://commandcode.ai/docs/reference/slash-commands.
|
|
8
|
+
*/
|
|
9
|
+
import path from 'path';
|
|
10
|
+
const COMMAND_CODE_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m;
|
|
11
|
+
function injectCommandCodeArgs(body) {
|
|
12
|
+
if (/^\*\*Provided arguments\*\*:\s*(?:\$(?:ARGUMENTS|@)|\$\{(?:ARGUMENTS|@)\})\s*$/m.test(body)) {
|
|
13
|
+
return body;
|
|
14
|
+
}
|
|
15
|
+
return body.replace(COMMAND_CODE_INPUT_HEADING, (heading) => `${heading}\n**Provided arguments**: $ARGUMENTS`);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Command Code adapter for command generation.
|
|
19
|
+
* File path: .commandcode/commands/opsx-<id>.md
|
|
20
|
+
* Format: plain Markdown with $ARGUMENTS injected after the input contract
|
|
21
|
+
*
|
|
22
|
+
* Command Code executes the full trimmed file body and substitutes invocation
|
|
23
|
+
* arguments only where the body includes one of its argument placeholders.
|
|
24
|
+
*/
|
|
25
|
+
export const commandCodeAdapter = {
|
|
26
|
+
toolId: 'command-code',
|
|
27
|
+
getFilePath(commandId) {
|
|
28
|
+
return path.join('.commandcode', 'commands', `opsx-${commandId}.md`);
|
|
29
|
+
},
|
|
30
|
+
formatFile(content) {
|
|
31
|
+
return `${injectCommandCodeArgs(content.body)}\n`;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=command-code.js.map
|
|
@@ -9,6 +9,7 @@ export { auggieAdapter } from './auggie.js';
|
|
|
9
9
|
export { bobAdapter } from './bob.js';
|
|
10
10
|
export { claudeAdapter } from './claude.js';
|
|
11
11
|
export { clineAdapter } from './cline.js';
|
|
12
|
+
export { commandCodeAdapter } from './command-code.js';
|
|
12
13
|
export { codebuddyAdapter } from './codebuddy.js';
|
|
13
14
|
export { continueAdapter } from './continue.js';
|
|
14
15
|
export { costrictAdapter } from './costrict.js';
|
|
@@ -9,6 +9,7 @@ export { auggieAdapter } from './auggie.js';
|
|
|
9
9
|
export { bobAdapter } from './bob.js';
|
|
10
10
|
export { claudeAdapter } from './claude.js';
|
|
11
11
|
export { clineAdapter } from './cline.js';
|
|
12
|
+
export { commandCodeAdapter } from './command-code.js';
|
|
12
13
|
export { codebuddyAdapter } from './codebuddy.js';
|
|
13
14
|
export { continueAdapter } from './continue.js';
|
|
14
15
|
export { costrictAdapter } from './costrict.js';
|
|
@@ -10,6 +10,7 @@ import { auggieAdapter } from './adapters/auggie.js';
|
|
|
10
10
|
import { bobAdapter } from './adapters/bob.js';
|
|
11
11
|
import { claudeAdapter } from './adapters/claude.js';
|
|
12
12
|
import { clineAdapter } from './adapters/cline.js';
|
|
13
|
+
import { commandCodeAdapter } from './adapters/command-code.js';
|
|
13
14
|
import { devinAdapter } from './adapters/devin.js';
|
|
14
15
|
import { codebuddyAdapter } from './adapters/codebuddy.js';
|
|
15
16
|
import { continueAdapter } from './adapters/continue.js';
|
|
@@ -45,6 +46,7 @@ export class CommandAdapterRegistry {
|
|
|
45
46
|
CommandAdapterRegistry.register(bobAdapter);
|
|
46
47
|
CommandAdapterRegistry.register(claudeAdapter);
|
|
47
48
|
CommandAdapterRegistry.register(clineAdapter);
|
|
49
|
+
CommandAdapterRegistry.register(commandCodeAdapter);
|
|
48
50
|
CommandAdapterRegistry.register(devinAdapter);
|
|
49
51
|
CommandAdapterRegistry.register(codebuddyAdapter);
|
|
50
52
|
CommandAdapterRegistry.register(continueAdapter);
|
|
@@ -97,6 +97,10 @@ export const COMMAND_REGISTRY = [
|
|
|
97
97
|
name: 'specs',
|
|
98
98
|
description: 'Validate all specs',
|
|
99
99
|
},
|
|
100
|
+
{
|
|
101
|
+
name: 'archived',
|
|
102
|
+
description: 'Validate that archived changes have all tasks completed (for pre-commit linting)',
|
|
103
|
+
},
|
|
100
104
|
COMMON_FLAGS.type,
|
|
101
105
|
COMMON_FLAGS.strict,
|
|
102
106
|
COMMON_FLAGS.jsonValidation,
|
|
@@ -226,6 +230,7 @@ export const COMMAND_REGISTRY = [
|
|
|
226
230
|
description: 'List available workflow schemas with descriptions',
|
|
227
231
|
flags: [
|
|
228
232
|
COMMON_FLAGS.json,
|
|
233
|
+
COMMON_FLAGS.store,
|
|
229
234
|
],
|
|
230
235
|
},
|
|
231
236
|
{
|
package/dist/core/config.d.ts
CHANGED
package/dist/core/config.js
CHANGED
|
@@ -18,41 +18,42 @@ export const OPENSPEC_MARKERS = {
|
|
|
18
18
|
end: '<!-- OPENSPEC:END -->'
|
|
19
19
|
};
|
|
20
20
|
export const AI_TOOLS = [
|
|
21
|
-
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq' },
|
|
22
|
-
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent' },
|
|
21
|
+
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq', requiresIdeRestart: true },
|
|
22
|
+
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent', requiresIdeRestart: true },
|
|
23
23
|
{ name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment' },
|
|
24
24
|
{ name: 'Bob Shell', value: 'bob', available: true, successLabel: 'Bob Shell', skillsDir: '.bob' },
|
|
25
25
|
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' },
|
|
26
|
-
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline' },
|
|
26
|
+
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline', requiresIdeRestart: true },
|
|
27
|
+
{ name: 'Command Code', value: 'command-code', available: true, successLabel: 'Command Code', skillsDir: '.commandcode' },
|
|
27
28
|
{ name: 'CodeArts', value: 'codeartsagent', available: true, successLabel: 'CodeArts', skillsDir: '.codeartsdoer' },
|
|
28
29
|
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.agents', legacySkillsDirs: ['.codex'], detectionPaths: ['.agents/skills', '.codex/skills'] },
|
|
29
|
-
{ name: 'Devin Desktop (formerly Windsurf)', value: 'devin', available: true, successLabel: 'Devin Desktop', skillsDir: '.devin', detectionPaths: ['.devin', '.windsurf'] },
|
|
30
|
+
{ name: 'Devin Desktop (formerly Windsurf)', value: 'devin', available: true, successLabel: 'Devin Desktop', skillsDir: '.devin', detectionPaths: ['.devin', '.windsurf'], requiresIdeRestart: true },
|
|
30
31
|
{ name: 'ForgeCode', value: 'forgecode', available: true, successLabel: 'ForgeCode', skillsDir: '.forge' },
|
|
31
32
|
{ name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code', skillsDir: '.codebuddy' },
|
|
32
|
-
{ name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue' },
|
|
33
|
-
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec' },
|
|
33
|
+
{ name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue', requiresIdeRestart: true },
|
|
34
|
+
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec', requiresIdeRestart: true },
|
|
34
35
|
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush', skillsDir: '.crush' },
|
|
35
|
-
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor' },
|
|
36
|
+
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor', requiresIdeRestart: true },
|
|
36
37
|
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' },
|
|
37
38
|
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' },
|
|
38
|
-
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', detectionPaths: ['.github/copilot-instructions.md', '.github/instructions', '.github/workflows/copilot-setup-steps.yml', '.github/prompts', '.github/agents', '.github/skills', '.github/.mcp.json'] },
|
|
39
|
+
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', detectionPaths: ['.github/copilot-instructions.md', '.github/instructions', '.github/workflows/copilot-setup-steps.yml', '.github/prompts', '.github/agents', '.github/skills', '.github/.mcp.json'], requiresIdeRestart: true },
|
|
39
40
|
{ name: 'Hermes Agent', value: 'hermes', available: true, successLabel: 'Hermes Agent', skillsDir: '.hermes', detectionPaths: ['.hermes', 'HERMES.md', '.hermes.md'], setupNote: "Hermes only loads skills from ~/.hermes/skills by default. Add this project's .hermes/skills directory to skills.external_dirs in ~/.hermes/config.yaml so Hermes picks up the generated OpenSpec skills." },
|
|
40
41
|
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },
|
|
41
|
-
{ name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie' },
|
|
42
|
-
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' },
|
|
42
|
+
{ name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie', requiresIdeRestart: true },
|
|
43
|
+
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode', requiresIdeRestart: true },
|
|
43
44
|
{ name: 'Kimi Code', value: 'kimi', available: true, successLabel: 'Kimi Code', skillsDir: '.kimi-code', detectionPaths: ['.kimi-code', '.kimi'] },
|
|
44
|
-
{ name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' },
|
|
45
|
-
{ name: 'Lingma', value: 'lingma', available: true, successLabel: 'Lingma', skillsDir: '.lingma' },
|
|
45
|
+
{ name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro', requiresIdeRestart: true },
|
|
46
|
+
{ name: 'Lingma', value: 'lingma', available: true, successLabel: 'Lingma', skillsDir: '.lingma', requiresIdeRestart: true },
|
|
46
47
|
{ name: 'MiniMax Code', value: 'minimax-code', available: true, successLabel: 'MiniMax Code', globalSkillsDir: '.minimax' },
|
|
47
48
|
{ name: 'Mistral Vibe', value: 'vibe', available: true, successLabel: 'Mistral Vibe', skillsDir: '.vibe' },
|
|
48
49
|
{ name: 'Oh My Pi', value: 'oh-my-pi', available: true, successLabel: 'Oh My Pi', skillsDir: '.omp' },
|
|
49
50
|
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' },
|
|
50
51
|
{ name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' },
|
|
51
|
-
{ name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' },
|
|
52
|
+
{ name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder', requiresIdeRestart: true },
|
|
52
53
|
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' },
|
|
53
54
|
{ name: 'Rovo Dev CLI', value: 'rovodev', available: true, successLabel: 'Rovo Dev CLI', skillsDir: '.rovodev', detectionPaths: ['.rovodev/skills', '.rovodev'] },
|
|
54
|
-
{ name: 'Zoo Code', value: 'roocode', available: true, successLabel: 'Zoo Code', skillsDir: '.roo' },
|
|
55
|
-
{ name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae' },
|
|
55
|
+
{ name: 'Zoo Code', value: 'roocode', available: true, successLabel: 'Zoo Code', skillsDir: '.roo', requiresIdeRestart: true },
|
|
56
|
+
{ name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae', requiresIdeRestart: true },
|
|
56
57
|
{ name: 'ZCode', value: 'zcode', available: true, successLabel: 'ZCode', skillsDir: '.zcode' },
|
|
57
58
|
// Vendor-neutral target for assistants that read the shared `.agents` root.
|
|
58
59
|
// Detection keys off `.agents/skills` rather than the bare root: frameworks use
|
package/dist/core/init.js
CHANGED
|
@@ -544,6 +544,7 @@ export class InitCommand {
|
|
|
544
544
|
skillsRoot: isGlobalSkillTarget ? skillsPath : projectPath,
|
|
545
545
|
isGlobalSkillTarget,
|
|
546
546
|
wasConfigured: preState?.configured ?? false,
|
|
547
|
+
requiresIdeRestart: tool.requiresIdeRestart,
|
|
547
548
|
});
|
|
548
549
|
}
|
|
549
550
|
return validatedTools;
|
|
@@ -930,14 +931,25 @@ export class InitCommand {
|
|
|
930
931
|
console.log();
|
|
931
932
|
console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
|
|
932
933
|
console.log(`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`);
|
|
933
|
-
// Restart instruction
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
//
|
|
938
|
-
|
|
934
|
+
// Restart instruction only when at least one IDE/editor-resident tool
|
|
935
|
+
// actually received a generated surface. Two conditions, coupled to the SAME
|
|
936
|
+
// tool: (1) its commands/skills are loaded by a long-running editor process
|
|
937
|
+
// (CLI tools pick the files up immediately, so a restart line would be wrong
|
|
938
|
+
// for them — see #1067), and (2) a surface was actually generated for it
|
|
939
|
+
// under the active delivery (an IDE tool that generated nothing has nothing a
|
|
940
|
+
// restart would pick up, even if a co-configured CLI tool did generate).
|
|
941
|
+
// Wording follows what the IDE tool itself generated, not the global
|
|
942
|
+
// aggregate: it must not say "commands" when the IDE tool only got skills
|
|
943
|
+
// while a co-configured CLI tool got commands. Not "slash commands" either:
|
|
944
|
+
// Amazon Q's generated files are prompt-library entries invoked with @, so a
|
|
945
|
+
// restart line promising slash commands would be wrong for it.
|
|
946
|
+
const restartCommandsGenerated = successfulTools.some((tool) => tool.requiresIdeRestart &&
|
|
947
|
+
shouldGenerateCommandsForTool(tool.value, activeDelivery));
|
|
948
|
+
const restartSkillsGenerated = successfulTools.some((tool) => tool.requiresIdeRestart &&
|
|
949
|
+
shouldGenerateSkillsForTool(tool.value, activeDelivery));
|
|
950
|
+
if (restartCommandsGenerated || restartSkillsGenerated) {
|
|
939
951
|
console.log();
|
|
940
|
-
console.log(chalk.white(
|
|
952
|
+
console.log(chalk.white(restartCommandsGenerated
|
|
941
953
|
? 'Restart your IDE for the new commands to take effect.'
|
|
942
954
|
: 'Restart your IDE for the new skills to take effect.'));
|
|
943
955
|
}
|
|
@@ -206,6 +206,16 @@ export declare function getLegacyWorkflowIdsForTool(detection: LegacyDetectionRe
|
|
|
206
206
|
* callers can safely perform the immediate, non-deferred cleanup pass.
|
|
207
207
|
*/
|
|
208
208
|
export declare function omitGlobalLegacyPromptFiles(detection: LegacyDetectionResult): LegacyDetectionResult;
|
|
209
|
+
/**
|
|
210
|
+
* Returns a detection snapshot with the repo-local slash-command artifacts of
|
|
211
|
+
* the given tools removed. The legacy-upgrade path uses this to skip cleaning a
|
|
212
|
+
* tool's legacy files when its replacement was deliberately NOT written — e.g. a
|
|
213
|
+
* Codex upgrade suppressed because the shared `.agents` root is already owned by
|
|
214
|
+
* another tool. Deleting the legacy prompt without writing its replacement would
|
|
215
|
+
* violate the cleanup contract ("remove X because replacement Y now exists") and
|
|
216
|
+
* strip the tool's only OpenSpec integration.
|
|
217
|
+
*/
|
|
218
|
+
export declare function omitToolLegacyArtifacts(detection: LegacyDetectionResult, toolIds: readonly string[]): LegacyDetectionResult;
|
|
209
219
|
/**
|
|
210
220
|
* Builds a detection snapshot containing only the selected global Codex prompt
|
|
211
221
|
* matches for replacement-gated cleanup.
|
|
@@ -614,41 +614,42 @@ export function formatDeferredGlobalPromptSummary(detection) {
|
|
|
614
614
|
*/
|
|
615
615
|
export function getToolsFromLegacyArtifacts(detection) {
|
|
616
616
|
const tools = new Set();
|
|
617
|
-
// Match directories to tool IDs
|
|
618
617
|
for (const dir of detection.slashCommandDirs) {
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
break;
|
|
623
|
-
}
|
|
624
|
-
}
|
|
618
|
+
const toolId = legacyToolIdForDir(dir);
|
|
619
|
+
if (toolId)
|
|
620
|
+
tools.add(toolId);
|
|
625
621
|
}
|
|
626
|
-
// Match files to tool IDs using glob patterns
|
|
627
622
|
for (const file of detection.slashCommandFiles) {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
if (pattern.type === 'files' && pattern.pattern) {
|
|
632
|
-
const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
|
|
633
|
-
let matched = false;
|
|
634
|
-
for (const p of patterns) {
|
|
635
|
-
const regex = globToRegex(p);
|
|
636
|
-
if (regex.test(normalizedFile)) {
|
|
637
|
-
tools.add(toolId);
|
|
638
|
-
matched = true;
|
|
639
|
-
break;
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
if (matched)
|
|
643
|
-
break;
|
|
644
|
-
}
|
|
645
|
-
}
|
|
623
|
+
const toolId = legacyToolIdForFile(file);
|
|
624
|
+
if (toolId)
|
|
625
|
+
tools.add(toolId);
|
|
646
626
|
}
|
|
647
627
|
for (const prompt of getLegacyGlobalPromptMatches(detection)) {
|
|
648
628
|
tools.add(prompt.toolId);
|
|
649
629
|
}
|
|
650
630
|
return Array.from(tools);
|
|
651
631
|
}
|
|
632
|
+
/** The tool that owns a repo-local legacy slash-command directory, if any. */
|
|
633
|
+
function legacyToolIdForDir(dir) {
|
|
634
|
+
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
|
|
635
|
+
if (pattern.type === 'directory' && pattern.path === dir)
|
|
636
|
+
return toolId;
|
|
637
|
+
}
|
|
638
|
+
return undefined;
|
|
639
|
+
}
|
|
640
|
+
/** The tool that owns a repo-local legacy slash-command file, if any. */
|
|
641
|
+
function legacyToolIdForFile(file) {
|
|
642
|
+
// Normalize to forward slashes so the glob patterns match on Windows too.
|
|
643
|
+
const normalizedFile = normalizePathForMatch(file);
|
|
644
|
+
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
|
|
645
|
+
if (pattern.type !== 'files' || !pattern.pattern)
|
|
646
|
+
continue;
|
|
647
|
+
const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
|
|
648
|
+
if (patterns.some((p) => globToRegex(p).test(normalizedFile)))
|
|
649
|
+
return toolId;
|
|
650
|
+
}
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
652
653
|
/**
|
|
653
654
|
* Normalizes global Codex prompt matches so callers can rely on workflow-aware
|
|
654
655
|
* metadata even when older detection results only carry file paths.
|
|
@@ -699,6 +700,27 @@ export function omitGlobalLegacyPromptFiles(detection) {
|
|
|
699
700
|
nextDetection.hasLegacyArtifacts = hasLegacyArtifacts(nextDetection);
|
|
700
701
|
return nextDetection;
|
|
701
702
|
}
|
|
703
|
+
/**
|
|
704
|
+
* Returns a detection snapshot with the repo-local slash-command artifacts of
|
|
705
|
+
* the given tools removed. The legacy-upgrade path uses this to skip cleaning a
|
|
706
|
+
* tool's legacy files when its replacement was deliberately NOT written — e.g. a
|
|
707
|
+
* Codex upgrade suppressed because the shared `.agents` root is already owned by
|
|
708
|
+
* another tool. Deleting the legacy prompt without writing its replacement would
|
|
709
|
+
* violate the cleanup contract ("remove X because replacement Y now exists") and
|
|
710
|
+
* strip the tool's only OpenSpec integration.
|
|
711
|
+
*/
|
|
712
|
+
export function omitToolLegacyArtifacts(detection, toolIds) {
|
|
713
|
+
if (toolIds.length === 0)
|
|
714
|
+
return detection;
|
|
715
|
+
const skip = new Set(toolIds);
|
|
716
|
+
const nextDetection = {
|
|
717
|
+
...detection,
|
|
718
|
+
slashCommandDirs: detection.slashCommandDirs.filter((dir) => !skip.has(legacyToolIdForDir(dir) ?? '')),
|
|
719
|
+
slashCommandFiles: detection.slashCommandFiles.filter((file) => !skip.has(legacyToolIdForFile(file) ?? '')),
|
|
720
|
+
};
|
|
721
|
+
nextDetection.hasLegacyArtifacts = hasLegacyArtifacts(nextDetection);
|
|
722
|
+
return nextDetection;
|
|
723
|
+
}
|
|
702
724
|
/**
|
|
703
725
|
* Builds a detection snapshot containing only the selected global Codex prompt
|
|
704
726
|
* matches for replacement-gated cleanup.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildCodeFenceMask } from './requirement-text.js';
|
|
1
|
+
import { buildCodeFenceMask, SCENARIO_HEADER } from './requirement-text.js';
|
|
2
2
|
export function normalizeRequirementName(name) {
|
|
3
3
|
return name.trim();
|
|
4
4
|
}
|
|
@@ -289,25 +289,57 @@ export function findMissingCurrentScenarios(current, incoming) {
|
|
|
289
289
|
}
|
|
290
290
|
return missing;
|
|
291
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* Any non-fenced level-4 header on the given (masked) line. Reuses the spec
|
|
294
|
+
* path's SCENARIO_HEADER so the two counters cannot drift apart.
|
|
295
|
+
*/
|
|
296
|
+
function scenarioHeaderAt(lines, mask, index) {
|
|
297
|
+
return !mask[index] && SCENARIO_HEADER.test(lines[index]);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* The scenario name for a `#### ` header, matching the label the author reads:
|
|
301
|
+
* the header text with the leading `####`, an optional CommonMark closing `#`
|
|
302
|
+
* run (`#### Foo ####` renders as `Foo`), and an optional `Scenario:` prefix
|
|
303
|
+
* stripped. Both the current and incoming blocks run through here, so the
|
|
304
|
+
* comparison in findMissingCurrentScenarios stays internally consistent
|
|
305
|
+
* regardless of label — and two headers that render to the same title (one
|
|
306
|
+
* ATX-closed, one not) are not mistaken for a dropped scenario.
|
|
307
|
+
*/
|
|
308
|
+
function scenarioNameAt(line) {
|
|
309
|
+
return line
|
|
310
|
+
.replace(SCENARIO_HEADER, '')
|
|
311
|
+
// Optional ATX closing sequence. CommonMark only treats a trailing `#` run
|
|
312
|
+
// as a close when it is preceded by a space or tab — not any Unicode space —
|
|
313
|
+
// so this uses `[ \t]`, not `\s`. A looser `\s` could strip a `#` run after
|
|
314
|
+
// an exotic space (e.g. NBSP) that CommonMark keeps, folding two distinct
|
|
315
|
+
// scenario names into one and masking a real loss. `[ \t]` keeps the fold
|
|
316
|
+
// faithful to how the header actually renders.
|
|
317
|
+
.replace(/[ \t]+#+[ \t]*$/, '')
|
|
318
|
+
.replace(/^Scenario:\s*/i, '')
|
|
319
|
+
.trim();
|
|
320
|
+
}
|
|
292
321
|
function parseScenarioBlocks(requirementRaw) {
|
|
293
322
|
const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n');
|
|
294
|
-
// A
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
323
|
+
// A scenario is ANY non-fenced `#### ` header, matching the spec path's
|
|
324
|
+
// SCENARIO_HEADER / countScenarios (requirement-text.ts) exactly — not only
|
|
325
|
+
// `#### Scenario:`. The two MUST agree: a level-4 child whose header is not
|
|
326
|
+
// literally `Scenario:` (e.g. `#### Edge case`) is still a scenario the spec
|
|
327
|
+
// path counts, so a MODIFIED block that drops it would otherwise slip past
|
|
328
|
+
// this loss check and be deleted by archive with no error (the parity the
|
|
329
|
+
// SCENARIO_HEADER comment warns not to break). A `####` inside a fenced
|
|
330
|
+
// example is masked out, matching countScenarios.
|
|
298
331
|
const mask = buildCodeFenceMask(lines);
|
|
299
332
|
const scenarios = [];
|
|
300
333
|
let index = 0;
|
|
301
334
|
while (index < lines.length) {
|
|
302
|
-
|
|
303
|
-
if (!headerMatch) {
|
|
335
|
+
if (!scenarioHeaderAt(lines, mask, index)) {
|
|
304
336
|
index++;
|
|
305
337
|
continue;
|
|
306
338
|
}
|
|
307
339
|
const start = index;
|
|
308
|
-
const name =
|
|
340
|
+
const name = scenarioNameAt(lines[index]);
|
|
309
341
|
index++;
|
|
310
|
-
while (index < lines.length && (mask
|
|
342
|
+
while (index < lines.length && !scenarioHeaderAt(lines, mask, index)) {
|
|
311
343
|
index++;
|
|
312
344
|
}
|
|
313
345
|
scenarios.push({
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
* `validate <change>`, `validate <spec>`, and `archive`.
|
|
10
10
|
*/
|
|
11
11
|
export { buildCodeFenceMask } from './code-fence.js';
|
|
12
|
+
/**
|
|
13
|
+
* A level-4 header. Deliberately matches ANY `####` header, not only
|
|
14
|
+
* `#### Scenario:` — the spec path treats every level-4 child of a requirement
|
|
15
|
+
* as a scenario, so the delta counter must too (parity). The delta/loss path
|
|
16
|
+
* reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
|
|
17
|
+
* keep both paths on it rather than reintroducing a separate `Scenario:` regex.
|
|
18
|
+
*/
|
|
19
|
+
export declare const SCENARIO_HEADER: RegExp;
|
|
12
20
|
/**
|
|
13
21
|
* The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
|
|
14
22
|
* as whole words so the change-delta reader and the schema-based reader accept
|
|
@@ -19,10 +19,11 @@ const HEADER_LINE = /^#{1,6}\s/;
|
|
|
19
19
|
/**
|
|
20
20
|
* A level-4 header. Deliberately matches ANY `####` header, not only
|
|
21
21
|
* `#### Scenario:` — the spec path treats every level-4 child of a requirement
|
|
22
|
-
* as a scenario, so the delta counter must too (parity).
|
|
23
|
-
*
|
|
22
|
+
* as a scenario, so the delta counter must too (parity). The delta/loss path
|
|
23
|
+
* reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
|
|
24
|
+
* keep both paths on it rather than reintroducing a separate `Scenario:` regex.
|
|
24
25
|
*/
|
|
25
|
-
const SCENARIO_HEADER = /^####\s+/;
|
|
26
|
+
export const SCENARIO_HEADER = /^####\s+/;
|
|
26
27
|
/**
|
|
27
28
|
* The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
|
|
28
29
|
* as whole words so the change-delta reader and the schema-based reader accept
|
|
@@ -119,7 +119,7 @@ export declare function toPlanningHome(root: ResolvedOpenSpecRoot): PlanningHome
|
|
|
119
119
|
export declare function resolveRootForCommand(selector: StoreSelectorOptions, output?: {
|
|
120
120
|
json?: boolean;
|
|
121
121
|
failurePayload?: Record<string, unknown>;
|
|
122
|
-
/**
|
|
122
|
+
/** Commands that require an existing root set this to false. */
|
|
123
123
|
allowImplicitRoot?: boolean;
|
|
124
124
|
}): Promise<ResolvedOpenSpecRoot | null>;
|
|
125
125
|
//# sourceMappingURL=root-selection.d.ts.map
|
|
@@ -12,5 +12,20 @@ export declare function reconcileSharedSkillTargets(projectPath: string, tools:
|
|
|
12
12
|
* Non-shared roots are always active.
|
|
13
13
|
*/
|
|
14
14
|
export declare function isSharedSkillTargetActive(projectPath: string, toolId: string): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* The tool that already owns `toolId`'s shared skills root, when a DIFFERENT
|
|
17
|
+
* one does. Returns the owner's tool id only when the root already carries an
|
|
18
|
+
* ownership signal (a marker or generated skills) AND reconciliation resolves
|
|
19
|
+
* it to another tool. An empty or unclaimed root returns undefined, so a
|
|
20
|
+
* genuine first-time legacy upgrade — e.g. a Codex-only user with no `.agents`
|
|
21
|
+
* yet — is never reported as owned.
|
|
22
|
+
*/
|
|
23
|
+
export declare function sharedSkillRootOwner(projectPath: string, toolId: string): string | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Whether generating `toolId` into its shared skills root would clobber a tree
|
|
26
|
+
* a DIFFERENT tool already owns — the guard the legacy-upgrade path uses before
|
|
27
|
+
* writing skills. See {@link sharedSkillRootOwner} for the ownership rules.
|
|
28
|
+
*/
|
|
29
|
+
export declare function sharedSkillRootOwnedByOther(projectPath: string, toolId: string): boolean;
|
|
15
30
|
export declare function writeSharedSkillTarget(projectPath: string, toolId: string): void;
|
|
16
31
|
//# sourceMappingURL=shared-skill-target.d.ts.map
|
|
@@ -141,6 +141,36 @@ export function isSharedSkillTargetActive(projectPath, toolId) {
|
|
|
141
141
|
return reconcileSharedSkillTargets(projectPath, sharingRoot)
|
|
142
142
|
.some((candidate) => candidate.value === toolId);
|
|
143
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* The tool that already owns `toolId`'s shared skills root, when a DIFFERENT
|
|
146
|
+
* one does. Returns the owner's tool id only when the root already carries an
|
|
147
|
+
* ownership signal (a marker or generated skills) AND reconciliation resolves
|
|
148
|
+
* it to another tool. An empty or unclaimed root returns undefined, so a
|
|
149
|
+
* genuine first-time legacy upgrade — e.g. a Codex-only user with no `.agents`
|
|
150
|
+
* yet — is never reported as owned.
|
|
151
|
+
*/
|
|
152
|
+
export function sharedSkillRootOwner(projectPath, toolId) {
|
|
153
|
+
const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
|
|
154
|
+
if (!tool?.skillsDir)
|
|
155
|
+
return undefined;
|
|
156
|
+
const sharingRoot = AI_TOOLS.filter((candidate) => candidate.skillsDir === tool.skillsDir);
|
|
157
|
+
if (sharingRoot.length < 2)
|
|
158
|
+
return undefined;
|
|
159
|
+
const hasOwnerSignal = readSharedSkillTarget(projectPath, tool.skillsDir) !== undefined ||
|
|
160
|
+
hasCurrentSkills(projectPath, tool.skillsDir);
|
|
161
|
+
if (!hasOwnerSignal)
|
|
162
|
+
return undefined;
|
|
163
|
+
const owner = reconcileSharedSkillTargets(projectPath, sharingRoot)[0]?.value;
|
|
164
|
+
return owner && owner !== toolId ? owner : undefined;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Whether generating `toolId` into its shared skills root would clobber a tree
|
|
168
|
+
* a DIFFERENT tool already owns — the guard the legacy-upgrade path uses before
|
|
169
|
+
* writing skills. See {@link sharedSkillRootOwner} for the ownership rules.
|
|
170
|
+
*/
|
|
171
|
+
export function sharedSkillRootOwnedByOther(projectPath, toolId) {
|
|
172
|
+
return sharedSkillRootOwner(projectPath, toolId) !== undefined;
|
|
173
|
+
}
|
|
144
174
|
export function writeSharedSkillTarget(projectPath, toolId) {
|
|
145
175
|
const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
|
|
146
176
|
if (!tool?.skillsDir)
|
package/dist/core/specs-apply.js
CHANGED
|
@@ -404,10 +404,17 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
404
404
|
.concat(keptOrder.map((b) => b.raw))
|
|
405
405
|
.join('\n\n')
|
|
406
406
|
.trimEnd();
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
407
|
+
// The blank lines surrounding `## Requirements` belong to no slice: `before`
|
|
408
|
+
// and `after` carry at most one trailing/leading '\n' by construction, and
|
|
409
|
+
// the body is trimEnd()ed. Joining the slices with a bare '\n' therefore
|
|
410
|
+
// glued the heading to the Purpose paragraph and the first requirement, so
|
|
411
|
+
// every archive rewrote a well-formatted spec into that shape. Separate
|
|
412
|
+
// non-empty slices with one blank line instead.
|
|
413
|
+
const rebuilt = [parts.before.trimEnd(), parts.headerLine, reqBody, parts.after.trim()]
|
|
414
|
+
.filter((s) => s !== '')
|
|
415
|
+
.join('\n\n')
|
|
416
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
417
|
+
.trimEnd() + '\n';
|
|
411
418
|
return {
|
|
412
419
|
rebuilt,
|
|
413
420
|
counts: {
|