@fission-ai/openspec 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -84,6 +84,9 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
84
84
  | **Claude Code** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` |
85
85
  | **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
86
86
  | **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
87
+ | **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) |
88
+
89
+ Kilo Code discovers team workflows automatically. Save the generated files under `.kilocode/workflows/` and trigger them from the command palette with `/openspec-proposal.md`, `/openspec-apply.md`, or `/openspec-archive.md`.
87
90
 
88
91
  #### AGENTS.md Compatible
89
92
  These tools automatically read workflow instructions from `openspec/AGENTS.md`. Ask them to follow the OpenSpec workflow if they need a reminder. Learn more about the [AGENTS.md convention](https://agents.md/).
@@ -121,8 +124,8 @@ openspec init
121
124
  ```
122
125
 
123
126
  **What happens during initialization:**
124
- - You'll be prompted to select your AI tool (Claude Code, Cursor, etc.)
125
- - OpenSpec automatically configures slash commands or `AGENTS.md` based on your selection
127
+ - You'll be prompted to pick any natively supported AI tools (Claude Code, Cursor, OpenCode, etc.); other assistants always rely on the shared `AGENTS.md` stub
128
+ - OpenSpec automatically configures slash commands for the tools you choose and always writes a managed `AGENTS.md` hand-off at the project root
126
129
  - A new `openspec/` directory structure is created in your project
127
130
 
128
131
  **After setup:**
@@ -4,9 +4,10 @@ export const OPENSPEC_MARKERS = {
4
4
  end: '<!-- OPENSPEC:END -->'
5
5
  };
6
6
  export const AI_TOOLS = [
7
- { name: 'Claude Code (✅ OpenSpec custom slash commands available)', value: 'claude', available: true, successLabel: 'Claude Code' },
8
- { name: 'Cursor (✅ OpenSpec custom slash commands available)', value: 'cursor', available: true, successLabel: 'Cursor' },
9
- { name: 'OpenCode (✅ OpenSpec custom slash commands available)', value: 'opencode', available: true, successLabel: 'OpenCode' },
10
- { name: 'AGENTS.md (works with Codex, Amp, Copilot, …)', value: 'agents', available: true, successLabel: 'your AGENTS.md-compatible assistant' }
7
+ { name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code' },
8
+ { name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
9
+ { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
10
+ { name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' },
11
+ { name: 'AGENTS.md (works with Codex, Amp, VS Code, GitHub Copilot, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }
11
12
  ];
12
13
  //# sourceMappingURL=config.js.map
@@ -0,0 +1,9 @@
1
+ import { SlashCommandConfigurator } from "./base.js";
2
+ import { SlashCommandId } from "../../templates/index.js";
3
+ export declare class KiloCodeSlashCommandConfigurator extends SlashCommandConfigurator {
4
+ readonly toolId = "kilocode";
5
+ readonly isAvailable = true;
6
+ protected getRelativePath(id: SlashCommandId): string;
7
+ protected getFrontmatter(_id: SlashCommandId): string | undefined;
8
+ }
9
+ //# sourceMappingURL=kilocode.d.ts.map
@@ -0,0 +1,17 @@
1
+ import { SlashCommandConfigurator } from "./base.js";
2
+ const FILE_PATHS = {
3
+ proposal: ".kilocode/workflows/openspec-proposal.md",
4
+ apply: ".kilocode/workflows/openspec-apply.md",
5
+ archive: ".kilocode/workflows/openspec-archive.md"
6
+ };
7
+ export class KiloCodeSlashCommandConfigurator extends SlashCommandConfigurator {
8
+ toolId = "kilocode";
9
+ isAvailable = true;
10
+ getRelativePath(id) {
11
+ return FILE_PATHS[id];
12
+ }
13
+ getFrontmatter(_id) {
14
+ return undefined;
15
+ }
16
+ }
17
+ //# sourceMappingURL=kilocode.js.map
@@ -1,14 +1,17 @@
1
1
  import { ClaudeSlashCommandConfigurator } from './claude.js';
2
2
  import { CursorSlashCommandConfigurator } from './cursor.js';
3
+ import { KiloCodeSlashCommandConfigurator } from './kilocode.js';
3
4
  import { OpenCodeSlashCommandConfigurator } from './opencode.js';
4
5
  export class SlashCommandRegistry {
5
6
  static configurators = new Map();
6
7
  static {
7
8
  const claude = new ClaudeSlashCommandConfigurator();
8
9
  const cursor = new CursorSlashCommandConfigurator();
10
+ const kilocode = new KiloCodeSlashCommandConfigurator();
9
11
  const opencode = new OpenCodeSlashCommandConfigurator();
10
12
  this.configurators.set(claude.toolId, claude);
11
13
  this.configurators.set(cursor.toolId, cursor);
14
+ this.configurators.set(kilocode.toolId, kilocode);
12
15
  this.configurators.set(opencode.toolId, opencode);
13
16
  }
14
17
  static register(configurator) {
@@ -3,9 +3,16 @@ type ToolLabel = {
3
3
  annotation?: string;
4
4
  };
5
5
  type ToolWizardChoice = {
6
+ kind: 'heading' | 'info';
7
+ value: string;
8
+ label: ToolLabel;
9
+ selectable: false;
10
+ } | {
11
+ kind: 'option';
6
12
  value: string;
7
13
  label: ToolLabel;
8
14
  configured: boolean;
15
+ selectable: true;
9
16
  };
10
17
  type ToolWizardConfig = {
11
18
  extendMode: boolean;
@@ -29,6 +36,7 @@ export declare class InitCommand {
29
36
  private createDirectoryStructure;
30
37
  private generateFiles;
31
38
  private configureAITools;
39
+ private configureRootAgentsStub;
32
40
  private displaySuccessMessage;
33
41
  private formatToolNames;
34
42
  private renderBanner;
package/dist/core/init.js CHANGED
@@ -7,16 +7,11 @@ import { TemplateManager } from './templates/index.js';
7
7
  import { ToolRegistry } from './configurators/registry.js';
8
8
  import { SlashCommandRegistry } from './configurators/slash/registry.js';
9
9
  import { AI_TOOLS, OPENSPEC_DIR_NAME, } from './config.js';
10
+ import { PALETTE } from './styles/palette.js';
10
11
  const PROGRESS_SPINNER = {
11
12
  interval: 80,
12
13
  frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],
13
14
  };
14
- const PALETTE = {
15
- white: chalk.hex('#f4f4f4'),
16
- lightGray: chalk.hex('#c8c8c8'),
17
- midGray: chalk.hex('#8a8a8a'),
18
- darkGray: chalk.hex('#4a4a4a'),
19
- };
20
15
  const LETTER_MAP = {
21
16
  O: [' ████ ', '██ ██', '██ ██', '██ ██', ' ████ '],
22
17
  P: ['█████ ', '██ ██', '█████ ', '██ ', '██ '],
@@ -38,16 +33,27 @@ const parseToolLabel = (raw) => {
38
33
  annotation: match[2].trim(),
39
34
  };
40
35
  };
36
+ const isSelectableChoice = (choice) => choice.selectable;
37
+ const ROOT_STUB_CHOICE_VALUE = '__root_stub__';
38
+ const OTHER_TOOLS_HEADING_VALUE = '__heading-other__';
39
+ const LIST_SPACER_VALUE = '__list-spacer__';
41
40
  const toolSelectionWizard = createPrompt((config, done) => {
42
41
  const totalSteps = 3;
43
42
  const [step, setStep] = useState('intro');
44
- const [cursor, setCursor] = useState(0);
45
- const [selected, setSelected] = useState(() => config.initialSelected ?? []);
43
+ const selectableChoices = config.choices.filter(isSelectableChoice);
44
+ const initialCursorIndex = config.choices.findIndex((choice) => choice.selectable);
45
+ const [cursor, setCursor] = useState(initialCursorIndex === -1 ? 0 : initialCursorIndex);
46
+ const [selected, setSelected] = useState(() => {
47
+ const initial = new Set((config.initialSelected ?? []).filter((value) => selectableChoices.some((choice) => choice.value === value)));
48
+ return selectableChoices
49
+ .map((choice) => choice.value)
50
+ .filter((value) => initial.has(value));
51
+ });
46
52
  const [error, setError] = useState(null);
47
53
  const selectedSet = new Set(selected);
48
- const pageSize = Math.max(Math.min(config.choices.length, 7), 1);
54
+ const pageSize = Math.max(config.choices.length, 1);
49
55
  const updateSelected = (next) => {
50
- const ordered = config.choices
56
+ const ordered = selectableChoices
51
57
  .map((choice) => choice.value)
52
58
  .filter((value) => next.has(value));
53
59
  setSelected(ordered);
@@ -56,8 +62,13 @@ const toolSelectionWizard = createPrompt((config, done) => {
56
62
  items: config.choices,
57
63
  active: cursor,
58
64
  pageSize,
59
- loop: config.choices.length > 1,
65
+ loop: false,
60
66
  renderItem: ({ item, isActive }) => {
67
+ if (!item.selectable) {
68
+ const prefix = item.kind === 'info' ? ' ' : '';
69
+ const textColor = item.kind === 'heading' ? PALETTE.lightGray : PALETTE.midGray;
70
+ return `${PALETTE.midGray(' ')} ${PALETTE.midGray(' ')} ${textColor(`${prefix}${item.label.primary}`)}`;
71
+ }
61
72
  const isSelected = selectedSet.has(item.value);
62
73
  const cursorSymbol = isActive
63
74
  ? PALETTE.white('›')
@@ -66,10 +77,32 @@ const toolSelectionWizard = createPrompt((config, done) => {
66
77
  ? PALETTE.white('◉')
67
78
  : PALETTE.midGray('○');
68
79
  const nameColor = isActive ? PALETTE.white : PALETTE.midGray;
69
- const label = `${nameColor(item.label.primary)}${item.configured ? PALETTE.midGray(' (already configured)') : ''}`;
80
+ const annotation = item.label.annotation
81
+ ? PALETTE.midGray(` (${item.label.annotation})`)
82
+ : '';
83
+ const configuredNote = item.configured
84
+ ? PALETTE.midGray(' (already configured)')
85
+ : '';
86
+ const label = `${nameColor(item.label.primary)}${annotation}${configuredNote}`;
70
87
  return `${cursorSymbol} ${indicator} ${label}`;
71
88
  },
72
89
  });
90
+ const moveCursor = (direction) => {
91
+ if (selectableChoices.length === 0) {
92
+ return;
93
+ }
94
+ let nextIndex = cursor;
95
+ while (true) {
96
+ nextIndex = nextIndex + direction;
97
+ if (nextIndex < 0 || nextIndex >= config.choices.length) {
98
+ return;
99
+ }
100
+ if (config.choices[nextIndex]?.selectable) {
101
+ setCursor(nextIndex);
102
+ return;
103
+ }
104
+ }
105
+ };
73
106
  useKeypress((key) => {
74
107
  if (step === 'intro') {
75
108
  if (isEnterKey(key)) {
@@ -79,20 +112,18 @@ const toolSelectionWizard = createPrompt((config, done) => {
79
112
  }
80
113
  if (step === 'select') {
81
114
  if (isUpKey(key)) {
82
- const previousIndex = cursor <= 0 ? config.choices.length - 1 : cursor - 1;
83
- setCursor(previousIndex);
115
+ moveCursor(-1);
84
116
  setError(null);
85
117
  return;
86
118
  }
87
119
  if (isDownKey(key)) {
88
- const nextIndex = cursor >= config.choices.length - 1 ? 0 : cursor + 1;
89
- setCursor(nextIndex);
120
+ moveCursor(1);
90
121
  setError(null);
91
122
  return;
92
123
  }
93
124
  if (isSpaceKey(key)) {
94
125
  const current = config.choices[cursor];
95
- if (!current)
126
+ if (!current || !current.selectable)
96
127
  return;
97
128
  const next = new Set(selected);
98
129
  if (next.has(current.value)) {
@@ -106,16 +137,13 @@ const toolSelectionWizard = createPrompt((config, done) => {
106
137
  return;
107
138
  }
108
139
  if (isEnterKey(key)) {
109
- if (selected.length === 0) {
110
- setError('Select at least one AI tool to continue.');
111
- return;
112
- }
113
140
  setStep('review');
114
141
  setError(null);
115
142
  return;
116
143
  }
117
144
  if (key.name === 'escape') {
118
- setSelected([]);
145
+ const next = new Set();
146
+ updateSelected(next);
119
147
  setError(null);
120
148
  }
121
149
  return;
@@ -124,7 +152,7 @@ const toolSelectionWizard = createPrompt((config, done) => {
124
152
  if (isEnterKey(key)) {
125
153
  const finalSelection = config.choices
126
154
  .map((choice) => choice.value)
127
- .filter((value) => selectedSet.has(value));
155
+ .filter((value) => selectedSet.has(value) && value !== ROOT_STUB_CHOICE_VALUE);
128
156
  done(finalSelection);
129
157
  return;
130
158
  }
@@ -134,9 +162,21 @@ const toolSelectionWizard = createPrompt((config, done) => {
134
162
  }
135
163
  }
136
164
  });
137
- const selectedNames = config.choices
138
- .filter((choice) => selectedSet.has(choice.value))
139
- .map((choice) => choice.label.primary);
165
+ const rootStubChoice = selectableChoices.find((choice) => choice.value === ROOT_STUB_CHOICE_VALUE);
166
+ const rootStubSelected = rootStubChoice
167
+ ? selectedSet.has(ROOT_STUB_CHOICE_VALUE)
168
+ : false;
169
+ const nativeChoices = selectableChoices.filter((choice) => choice.value !== ROOT_STUB_CHOICE_VALUE);
170
+ const selectedNativeChoices = nativeChoices.filter((choice) => selectedSet.has(choice.value));
171
+ const formatSummaryLabel = (choice) => {
172
+ const annotation = choice.label.annotation
173
+ ? PALETTE.midGray(` (${choice.label.annotation})`)
174
+ : '';
175
+ const configuredNote = choice.configured
176
+ ? PALETTE.midGray(' (already configured)')
177
+ : '';
178
+ return `${PALETTE.white(choice.label.primary)}${annotation}${configuredNote}`;
179
+ };
140
180
  const stepIndex = step === 'intro' ? 1 : step === 'select' ? 2 : 3;
141
181
  const lines = [];
142
182
  lines.push(PALETTE.midGray(`Step ${stepIndex}/${totalSteps}`));
@@ -159,13 +199,16 @@ const toolSelectionWizard = createPrompt((config, done) => {
159
199
  lines.push('');
160
200
  lines.push(page);
161
201
  lines.push('');
162
- if (selectedNames.length === 0) {
163
- lines.push(`${PALETTE.midGray('Selected')}: ${PALETTE.midGray('None selected yet')}`);
202
+ lines.push(PALETTE.midGray('Selected configuration:'));
203
+ if (rootStubSelected && rootStubChoice) {
204
+ lines.push(` ${PALETTE.white('-')} ${formatSummaryLabel(rootStubChoice)}`);
205
+ }
206
+ if (selectedNativeChoices.length === 0) {
207
+ lines.push(` ${PALETTE.midGray('- No natively supported providers selected')}`);
164
208
  }
165
209
  else {
166
- lines.push(PALETTE.midGray('Selected:'));
167
- selectedNames.forEach((name) => {
168
- lines.push(` ${PALETTE.white('-')} ${PALETTE.white(name)}`);
210
+ selectedNativeChoices.forEach((choice) => {
211
+ lines.push(` ${PALETTE.white('-')} ${formatSummaryLabel(choice)}`);
169
212
  });
170
213
  }
171
214
  }
@@ -173,12 +216,15 @@ const toolSelectionWizard = createPrompt((config, done) => {
173
216
  lines.push(PALETTE.white('Review selections'));
174
217
  lines.push(PALETTE.midGray('Press Enter to confirm or Backspace to adjust.'));
175
218
  lines.push('');
176
- if (selectedNames.length === 0) {
177
- lines.push(PALETTE.midGray('No tools selected. Press Backspace to return.'));
219
+ if (rootStubSelected && rootStubChoice) {
220
+ lines.push(`${PALETTE.white('')} ${formatSummaryLabel(rootStubChoice)}`);
221
+ }
222
+ if (selectedNativeChoices.length === 0) {
223
+ lines.push(PALETTE.midGray('No natively supported providers selected. Universal instructions will still be applied.'));
178
224
  }
179
225
  else {
180
- selectedNames.forEach((name) => {
181
- lines.push(`${PALETTE.white('▌')} ${PALETTE.white(name)}`);
226
+ selectedNativeChoices.forEach((choice) => {
227
+ lines.push(`${PALETTE.white('▌')} ${formatSummaryLabel(choice)}`);
182
228
  });
183
229
  }
184
230
  }
@@ -202,13 +248,6 @@ export class InitCommand {
202
248
  this.renderBanner(extendMode);
203
249
  // Get configuration (after validation to avoid prompts if validation fails)
204
250
  const config = await this.getConfiguration(existingToolStates, extendMode);
205
- if (config.aiTools.length === 0) {
206
- if (extendMode) {
207
- throw new Error(`OpenSpec seems to already be initialized at ${openspecPath}.\n` +
208
- `Use 'openspec update' to update the structure.`);
209
- }
210
- throw new Error('You must select at least one AI tool to configure.');
211
- }
212
251
  const availableTools = AI_TOOLS.filter((tool) => tool.available);
213
252
  const selectedIds = new Set(config.aiTools);
214
253
  const selectedTools = availableTools.filter((tool) => selectedIds.has(tool.value));
@@ -231,13 +270,13 @@ export class InitCommand {
231
270
  }
232
271
  // Step 2: Configure AI tools
233
272
  const toolSpinner = this.startSpinner('Configuring AI tools...');
234
- await this.configureAITools(projectPath, openspecDir, config.aiTools);
273
+ const rootStubStatus = await this.configureAITools(projectPath, openspecDir, config.aiTools);
235
274
  toolSpinner.stopAndPersist({
236
275
  symbol: PALETTE.white('▌'),
237
276
  text: PALETTE.white('AI tools configured'),
238
277
  });
239
278
  // Success message
240
- this.displaySuccessMessage(selectedTools, created, refreshed, skippedExisting, skipped, extendMode);
279
+ this.displaySuccessMessage(selectedTools, created, refreshed, skippedExisting, skipped, extendMode, rootStubStatus);
241
280
  }
242
281
  async validate(projectPath, _openspecPath) {
243
282
  const extendMode = await FileSystemUtils.directoryExists(_openspecPath);
@@ -253,25 +292,64 @@ export class InitCommand {
253
292
  }
254
293
  async promptForAITools(existingTools, extendMode) {
255
294
  const availableTools = AI_TOOLS.filter((tool) => tool.available);
256
- if (availableTools.length === 0) {
257
- return [];
258
- }
259
295
  const baseMessage = extendMode
260
- ? 'Which AI tools would you like to add or refresh?'
261
- : 'Which AI tools do you use?';
262
- const initialSelected = extendMode
296
+ ? 'Which natively supported AI tools would you like to add or refresh?'
297
+ : 'Which natively supported AI tools do you use?';
298
+ const initialNativeSelection = extendMode
263
299
  ? availableTools
264
300
  .filter((tool) => existingTools[tool.value])
265
301
  .map((tool) => tool.value)
266
302
  : [];
267
- return this.prompt({
268
- extendMode,
269
- baseMessage,
270
- choices: availableTools.map((tool) => ({
303
+ const initialSelected = Array.from(new Set(initialNativeSelection));
304
+ const choices = [
305
+ {
306
+ kind: 'heading',
307
+ value: '__heading-native__',
308
+ label: {
309
+ primary: 'Natively supported providers (✔ OpenSpec custom slash commands available)',
310
+ },
311
+ selectable: false,
312
+ },
313
+ ...availableTools.map((tool) => ({
314
+ kind: 'option',
271
315
  value: tool.value,
272
316
  label: parseToolLabel(tool.name),
273
317
  configured: Boolean(existingTools[tool.value]),
318
+ selectable: true,
274
319
  })),
320
+ ...(availableTools.length
321
+ ? [
322
+ {
323
+ kind: 'info',
324
+ value: LIST_SPACER_VALUE,
325
+ label: { primary: '' },
326
+ selectable: false,
327
+ },
328
+ ]
329
+ : []),
330
+ {
331
+ kind: 'heading',
332
+ value: OTHER_TOOLS_HEADING_VALUE,
333
+ label: {
334
+ primary: 'Other tools (use Universal AGENTS.md for Codex, Amp, VS Code, GitHub Copilot, …)',
335
+ },
336
+ selectable: false,
337
+ },
338
+ {
339
+ kind: 'option',
340
+ value: ROOT_STUB_CHOICE_VALUE,
341
+ label: {
342
+ primary: 'Universal AGENTS.md',
343
+ annotation: 'always available',
344
+ },
345
+ configured: extendMode,
346
+ selectable: true,
347
+ },
348
+ ];
349
+ return this.prompt({
350
+ extendMode,
351
+ baseMessage,
352
+ choices,
275
353
  initialSelected,
276
354
  });
277
355
  }
@@ -321,6 +399,7 @@ export class InitCommand {
321
399
  }
322
400
  }
323
401
  async configureAITools(projectPath, openspecDir, toolIds) {
402
+ const rootStubStatus = await this.configureRootAgentsStub(projectPath, openspecDir);
324
403
  for (const toolId of toolIds) {
325
404
  const configurator = ToolRegistry.get(toolId);
326
405
  if (configurator && configurator.isAvailable) {
@@ -331,8 +410,19 @@ export class InitCommand {
331
410
  await slashConfigurator.generateAll(projectPath, openspecDir);
332
411
  }
333
412
  }
413
+ return rootStubStatus;
334
414
  }
335
- displaySuccessMessage(selectedTools, created, refreshed, skippedExisting, skipped, extendMode) {
415
+ async configureRootAgentsStub(projectPath, openspecDir) {
416
+ const configurator = ToolRegistry.get('agents');
417
+ if (!configurator || !configurator.isAvailable) {
418
+ return 'skipped';
419
+ }
420
+ const stubPath = path.join(projectPath, configurator.configFileName);
421
+ const existed = await FileSystemUtils.fileExists(stubPath);
422
+ await configurator.configure(projectPath, openspecDir);
423
+ return existed ? 'updated' : 'created';
424
+ }
425
+ displaySuccessMessage(selectedTools, created, refreshed, skippedExisting, skipped, extendMode, rootStubStatus) {
336
426
  console.log(); // Empty line for spacing
337
427
  const successHeadline = extendMode
338
428
  ? 'OpenSpec tool configuration updated!'
@@ -341,6 +431,12 @@ export class InitCommand {
341
431
  console.log();
342
432
  console.log(PALETTE.lightGray('Tool summary:'));
343
433
  const summaryLines = [
434
+ rootStubStatus === 'created'
435
+ ? `${PALETTE.white('▌')} ${PALETTE.white('Root AGENTS.md stub created for other assistants')}`
436
+ : null,
437
+ rootStubStatus === 'updated'
438
+ ? `${PALETTE.lightGray('▌')} ${PALETTE.lightGray('Root AGENTS.md stub refreshed for other assistants')}`
439
+ : null,
344
440
  created.length
345
441
  ? `${PALETTE.white('▌')} ${PALETTE.white('Created:')} ${this.formatToolNames(created)}`
346
442
  : null,
@@ -380,7 +476,7 @@ export class InitCommand {
380
476
  .map((tool) => tool.successLabel ?? tool.name)
381
477
  .filter((name) => Boolean(name));
382
478
  if (names.length === 0)
383
- return PALETTE.lightGray('your AI assistant');
479
+ return PALETTE.lightGray('your AGENTS.md-compatible assistant');
384
480
  if (names.length === 1)
385
481
  return PALETTE.white(names[0]);
386
482
  const base = names.slice(0, -1).map((name) => PALETTE.white(name));
@@ -0,0 +1,7 @@
1
+ export declare const PALETTE: {
2
+ white: import("chalk").ChalkInstance;
3
+ lightGray: import("chalk").ChalkInstance;
4
+ midGray: import("chalk").ChalkInstance;
5
+ darkGray: import("chalk").ChalkInstance;
6
+ };
7
+ //# sourceMappingURL=palette.d.ts.map
@@ -0,0 +1,8 @@
1
+ import chalk from 'chalk';
2
+ export const PALETTE = {
3
+ white: chalk.hex('#f4f4f4'),
4
+ lightGray: chalk.hex('#c8c8c8'),
5
+ midGray: chalk.hex('#8a8a8a'),
6
+ darkGray: chalk.hex('#4a4a4a')
7
+ };
8
+ //# sourceMappingURL=palette.js.map
@@ -0,0 +1,2 @@
1
+ export declare const agentsRootStubTemplate = "# OpenSpec Instructions\n\nThese instructions are for AI assistants working in this project.\n\nAlways open `@/openspec/AGENTS.md` when the request:\n- Mentions planning or proposals (words like proposal, spec, change, plan)\n- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work\n- Sounds ambiguous and you need the authoritative spec before coding\n\nUse `@/openspec/AGENTS.md` to learn:\n- How to create and apply change proposals\n- Spec format and conventions\n- Project structure and guidelines\n\nKeep this managed block so 'openspec update' can refresh the instructions.\n";
2
+ //# sourceMappingURL=agents-root-stub.d.ts.map
@@ -0,0 +1,17 @@
1
+ export const agentsRootStubTemplate = `# OpenSpec Instructions
2
+
3
+ These instructions are for AI assistants working in this project.
4
+
5
+ Always open \`@/openspec/AGENTS.md\` when the request:
6
+ - Mentions planning or proposals (words like proposal, spec, change, plan)
7
+ - Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
8
+ - Sounds ambiguous and you need the authoritative spec before coding
9
+
10
+ Use \`@/openspec/AGENTS.md\` to learn:
11
+ - How to create and apply change proposals
12
+ - Spec format and conventions
13
+ - Project structure and guidelines
14
+
15
+ Keep this managed block so 'openspec update' can refresh the instructions.
16
+ `;
17
+ //# sourceMappingURL=agents-root-stub.js.map
@@ -1,2 +1,2 @@
1
- export { agentsTemplate as claudeTemplate } from './agents-template.js';
1
+ export { agentsRootStubTemplate as claudeTemplate } from './agents-root-stub.js';
2
2
  //# sourceMappingURL=claude-template.d.ts.map
@@ -1,2 +1,2 @@
1
- export { agentsTemplate as claudeTemplate } from './agents-template.js';
1
+ export { agentsRootStubTemplate as claudeTemplate } from './agents-root-stub.js';
2
2
  //# sourceMappingURL=claude-template.js.map
@@ -1,6 +1,7 @@
1
1
  import { agentsTemplate } from './agents-template.js';
2
2
  import { projectTemplate } from './project-template.js';
3
3
  import { claudeTemplate } from './claude-template.js';
4
+ import { agentsRootStubTemplate } from './agents-root-stub.js';
4
5
  import { getSlashCommandBody } from './slash-command-templates.js';
5
6
  export class TemplateManager {
6
7
  static getTemplates(context = {}) {
@@ -19,7 +20,7 @@ export class TemplateManager {
19
20
  return claudeTemplate;
20
21
  }
21
22
  static getAgentsStandardTemplate() {
22
- return agentsTemplate;
23
+ return agentsRootStubTemplate;
23
24
  }
24
25
  static getSlashCommandBody(id) {
25
26
  return getSlashCommandBody(id);
@@ -1,14 +1,14 @@
1
1
  const baseGuardrails = `**Guardrails**
2
2
  - Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
3
3
  - Keep changes tightly scoped to the requested outcome.
4
- - Refer to \`openspec/AGENTS.md\` if you need additional OpenSpec conventions or clarifications.`;
4
+ - Refer to \`openspec/AGENTS.md\` (located inside the \`openspec/\` directory—run \`ls openspec\` or \`openspec update\` if you don't see it) if you need additional OpenSpec conventions or clarifications.`;
5
5
  const proposalGuardrails = `${baseGuardrails}\n- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files.`;
6
6
  const proposalSteps = `**Steps**
7
7
  1. Review \`openspec/project.md\`, run \`openspec list\` and \`openspec list --specs\`, and inspect related code or docs (e.g., via \`rg\`/\`ls\`) to ground the proposal in current behaviour; note any gaps that require clarification.
8
8
  2. Choose a unique verb-led \`change-id\` and scaffold \`proposal.md\`, \`tasks.md\`, and \`design.md\` (when needed) under \`openspec/changes/<id>/\`.
9
9
  3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing.
10
10
  4. Capture architectural reasoning in \`design.md\` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs.
11
- 5. Draft spec deltas in \`changes/<id>/specs/\` using \`## ADDED|MODIFIED|REMOVED Requirements\` with at least one \`#### Scenario:\` per requirement and cross-reference related capabilities when relevant.
11
+ 5. Draft spec deltas in \`changes/<id>/specs/<capability>/spec.md\` (one folder per capability) using \`## ADDED|MODIFIED|REMOVED Requirements\` with at least one \`#### Scenario:\` per requirement and cross-reference related capabilities when relevant.
12
12
  6. Draft \`tasks.md\` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work.
13
13
  7. Validate with \`openspec validate <id> --strict\` and resolve every issue before sharing the proposal.`;
14
14
  const proposalReferences = `**Reference**
@@ -1,10 +1,9 @@
1
1
  import path from 'path';
2
2
  import { FileSystemUtils } from '../utils/file-system.js';
3
- import { OPENSPEC_DIR_NAME, OPENSPEC_MARKERS } from './config.js';
4
- import { agentsTemplate } from './templates/agents-template.js';
5
- import { TemplateManager } from './templates/index.js';
3
+ import { OPENSPEC_DIR_NAME } from './config.js';
6
4
  import { ToolRegistry } from './configurators/registry.js';
7
5
  import { SlashCommandRegistry } from './configurators/slash/registry.js';
6
+ import { agentsTemplate } from './templates/agents-template.js';
8
7
  export class UpdateCommand {
9
8
  async execute(projectPath) {
10
9
  const resolvedProjectPath = path.resolve(projectPath);
@@ -16,34 +15,36 @@ export class UpdateCommand {
16
15
  }
17
16
  // 2. Update AGENTS.md (full replacement)
18
17
  const agentsPath = path.join(openspecPath, 'AGENTS.md');
19
- const rootAgentsPath = path.join(resolvedProjectPath, 'AGENTS.md');
20
- const rootAgentsExisted = await FileSystemUtils.fileExists(rootAgentsPath);
21
18
  await FileSystemUtils.writeFile(agentsPath, agentsTemplate);
22
- const agentsStandardContent = TemplateManager.getAgentsStandardTemplate();
23
- await FileSystemUtils.updateFileWithMarkers(rootAgentsPath, agentsStandardContent, OPENSPEC_MARKERS.start, OPENSPEC_MARKERS.end);
24
19
  // 3. Update existing AI tool configuration files only
25
20
  const configurators = ToolRegistry.getAll();
26
21
  const slashConfigurators = SlashCommandRegistry.getAll();
27
- let updatedFiles = [];
28
- let failedFiles = [];
29
- let updatedSlashFiles = [];
30
- let failedSlashTools = [];
22
+ const updatedFiles = [];
23
+ const createdFiles = [];
24
+ const failedFiles = [];
25
+ const updatedSlashFiles = [];
26
+ const failedSlashTools = [];
31
27
  for (const configurator of configurators) {
32
28
  const configFilePath = path.join(resolvedProjectPath, configurator.configFileName);
33
- // Only update if the file already exists
34
- if (await FileSystemUtils.fileExists(configFilePath)) {
35
- try {
36
- if (!await FileSystemUtils.canWriteFile(configFilePath)) {
37
- throw new Error(`Insufficient permissions to modify ${configurator.configFileName}`);
38
- }
39
- await configurator.configure(resolvedProjectPath, openspecPath);
40
- updatedFiles.push(configurator.configFileName);
29
+ const fileExists = await FileSystemUtils.fileExists(configFilePath);
30
+ const shouldConfigure = fileExists || configurator.configFileName === 'AGENTS.md';
31
+ if (!shouldConfigure) {
32
+ continue;
33
+ }
34
+ try {
35
+ if (fileExists && !await FileSystemUtils.canWriteFile(configFilePath)) {
36
+ throw new Error(`Insufficient permissions to modify ${configurator.configFileName}`);
41
37
  }
42
- catch (error) {
43
- failedFiles.push(configurator.configFileName);
44
- console.error(`Failed to update ${configurator.configFileName}: ${error instanceof Error ? error.message : String(error)}`);
38
+ await configurator.configure(resolvedProjectPath, openspecPath);
39
+ updatedFiles.push(configurator.configFileName);
40
+ if (!fileExists) {
41
+ createdFiles.push(configurator.configFileName);
45
42
  }
46
43
  }
44
+ catch (error) {
45
+ failedFiles.push(configurator.configFileName);
46
+ console.error(`Failed to update ${configurator.configFileName}: ${error instanceof Error ? error.message : String(error)}`);
47
+ }
47
48
  }
48
49
  for (const slashConfigurator of slashConfigurators) {
49
50
  if (!slashConfigurator.isAvailable) {
@@ -51,30 +52,34 @@ export class UpdateCommand {
51
52
  }
52
53
  try {
53
54
  const updated = await slashConfigurator.updateExisting(resolvedProjectPath, openspecPath);
54
- updatedSlashFiles = updatedSlashFiles.concat(updated);
55
+ updatedSlashFiles.push(...updated);
55
56
  }
56
57
  catch (error) {
57
58
  failedSlashTools.push(slashConfigurator.toolId);
58
59
  console.error(`Failed to update slash commands for ${slashConfigurator.toolId}: ${error instanceof Error ? error.message : String(error)}`);
59
60
  }
60
61
  }
61
- // 4. Success message (ASCII-safe)
62
- const instructionUpdates = ['openspec/AGENTS.md'];
63
- instructionUpdates.push(`AGENTS.md${rootAgentsExisted ? '' : ' (created)'}`);
64
- const messages = [`Updated OpenSpec instructions (${instructionUpdates.join(', ')})`];
65
- if (updatedFiles.length > 0) {
66
- messages.push(`Updated AI tool files: ${updatedFiles.join(', ')}`);
62
+ const summaryParts = [];
63
+ const instructionFiles = ['openspec/AGENTS.md'];
64
+ if (updatedFiles.includes('AGENTS.md')) {
65
+ instructionFiles.push(createdFiles.includes('AGENTS.md') ? 'AGENTS.md (created)' : 'AGENTS.md');
67
66
  }
68
- if (updatedSlashFiles.length > 0) {
69
- messages.push(`Updated slash commands: ${updatedSlashFiles.join(', ')}`);
67
+ summaryParts.push(`Updated OpenSpec instructions (${instructionFiles.join(', ')})`);
68
+ const aiToolFiles = updatedFiles.filter((file) => file !== 'AGENTS.md');
69
+ if (aiToolFiles.length > 0) {
70
+ summaryParts.push(`Updated AI tool files: ${aiToolFiles.join(', ')}`);
70
71
  }
71
- if (failedFiles.length > 0) {
72
- messages.push(`Failed to update: ${failedFiles.join(', ')}`);
72
+ if (updatedSlashFiles.length > 0) {
73
+ summaryParts.push(`Updated slash commands: ${updatedSlashFiles.join(', ')}`);
73
74
  }
74
- if (failedSlashTools.length > 0) {
75
- messages.push(`Failed slash command updates: ${failedSlashTools.join(', ')}`);
75
+ const failedItems = [
76
+ ...failedFiles,
77
+ ...failedSlashTools.map((toolId) => `slash command refresh (${toolId})`),
78
+ ];
79
+ if (failedItems.length > 0) {
80
+ summaryParts.push(`Failed to update: ${failedItems.join(', ')}`);
76
81
  }
77
- console.log(messages.join('\n'));
82
+ console.log(summaryParts.join(' | '));
78
83
  }
79
84
  }
80
85
  //# sourceMappingURL=update.js.map
@@ -26,7 +26,7 @@ export declare const VALIDATION_MESSAGES: {
26
26
  readonly REQUIREMENT_TOO_LONG: "Requirement text is very long (>500 characters). Consider breaking it down.";
27
27
  readonly DELTA_DESCRIPTION_TOO_BRIEF: "Delta description is too brief";
28
28
  readonly DELTA_MISSING_REQUIREMENTS: "Delta should include requirements";
29
- readonly GUIDE_NO_DELTAS: "No deltas found. Ensure your change has a specs/ directory with .md files using delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one \"#### Scenario:\" block. Tip: run \"openspec change show <change-id> --json --deltas-only\" to inspect parsed deltas.";
29
+ readonly GUIDE_NO_DELTAS: "No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one \"#### Scenario:\" block. Tip: run \"openspec change show <change-id> --json --deltas-only\" to inspect parsed deltas.";
30
30
  readonly GUIDE_MISSING_SPEC_SECTIONS: "Missing required sections. Expected headers: \"## Purpose\" and \"## Requirements\". Example:\n## Purpose\n[brief purpose]\n\n## Requirements\n### Requirement: Clear requirement statement\nUsers SHALL ...\n\n#### Scenario: Descriptive name\n- **WHEN** ...\n- **THEN** ...";
31
31
  readonly GUIDE_MISSING_CHANGE_SECTIONS: "Missing required sections. Expected headers: \"## Why\" and \"## What Changes\". Ensure deltas are documented in specs/ using delta headers.";
32
32
  readonly GUIDE_SCENARIO_FORMAT: "Scenarios must use level-4 headers. Convert bullet lists into:\n#### Scenario: Short name\n- **WHEN** ...\n- **THEN** ...\n- **AND** ...";
@@ -32,7 +32,7 @@ export const VALIDATION_MESSAGES = {
32
32
  DELTA_DESCRIPTION_TOO_BRIEF: 'Delta description is too brief',
33
33
  DELTA_MISSING_REQUIREMENTS: 'Delta should include requirements',
34
34
  // Guidance snippets (appended to primary messages for remediation)
35
- GUIDE_NO_DELTAS: 'No deltas found. Ensure your change has a specs/ directory with .md files using delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one "#### Scenario:" block. Tip: run "openspec change show <change-id> --json --deltas-only" to inspect parsed deltas.',
35
+ GUIDE_NO_DELTAS: 'No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one "#### Scenario:" block. Tip: run "openspec change show <change-id> --json --deltas-only" to inspect parsed deltas.',
36
36
  GUIDE_MISSING_SPEC_SECTIONS: 'Missing required sections. Expected headers: "## Purpose" and "## Requirements". Example:\n## Purpose\n[brief purpose]\n\n## Requirements\n### Requirement: Clear requirement statement\nUsers SHALL ...\n\n#### Scenario: Descriptive name\n- **WHEN** ...\n- **THEN** ...',
37
37
  GUIDE_MISSING_CHANGE_SECTIONS: 'Missing required sections. Expected headers: "## Why" and "## What Changes". Ensure deltas are documented in specs/ using delta headers.',
38
38
  GUIDE_SCENARIO_FORMAT: 'Scenarios must use level-4 headers. Convert bullet lists into:\n#### Scenario: Short name\n- **WHEN** ...\n- **THEN** ...\n- **AND** ...',
@@ -1,5 +1,34 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
+ function isMarkerOnOwnLine(content, markerIndex, markerLength) {
4
+ let leftIndex = markerIndex - 1;
5
+ while (leftIndex >= 0 && content[leftIndex] !== '\n') {
6
+ const char = content[leftIndex];
7
+ if (char !== ' ' && char !== '\t' && char !== '\r') {
8
+ return false;
9
+ }
10
+ leftIndex--;
11
+ }
12
+ let rightIndex = markerIndex + markerLength;
13
+ while (rightIndex < content.length && content[rightIndex] !== '\n') {
14
+ const char = content[rightIndex];
15
+ if (char !== ' ' && char !== '\t' && char !== '\r') {
16
+ return false;
17
+ }
18
+ rightIndex++;
19
+ }
20
+ return true;
21
+ }
22
+ function findMarkerIndex(content, marker, fromIndex = 0) {
23
+ let currentIndex = content.indexOf(marker, fromIndex);
24
+ while (currentIndex !== -1) {
25
+ if (isMarkerOnOwnLine(content, currentIndex, marker.length)) {
26
+ return currentIndex;
27
+ }
28
+ currentIndex = content.indexOf(marker, currentIndex + marker.length);
29
+ }
30
+ return -1;
31
+ }
3
32
  export class FileSystemUtils {
4
33
  static async createDirectory(dirPath) {
5
34
  await fs.mkdir(dirPath, { recursive: true });
@@ -56,9 +85,14 @@ export class FileSystemUtils {
56
85
  let existingContent = '';
57
86
  if (await this.fileExists(filePath)) {
58
87
  existingContent = await this.readFile(filePath);
59
- const startIndex = existingContent.indexOf(startMarker);
60
- const endIndex = existingContent.indexOf(endMarker);
88
+ const startIndex = findMarkerIndex(existingContent, startMarker);
89
+ const endIndex = startIndex !== -1
90
+ ? findMarkerIndex(existingContent, endMarker, startIndex + startMarker.length)
91
+ : findMarkerIndex(existingContent, endMarker);
61
92
  if (startIndex !== -1 && endIndex !== -1) {
93
+ if (endIndex < startIndex) {
94
+ throw new Error(`Invalid marker state in ${filePath}. End marker appears before start marker.`);
95
+ }
62
96
  const before = existingContent.substring(0, startIndex);
63
97
  const after = existingContent.substring(endIndex + endMarker.length);
64
98
  existingContent = before + startMarker + '\n' + content + '\n' + endMarker + after;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fission-ai/openspec",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",