@fission-ai/openspec 1.13.1 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +15 -3
  2. package/dist/cli/index.js +39 -1
  3. package/dist/commands/spec.js +1 -0
  4. package/dist/commands/workflow/instructions.js +66 -18
  5. package/dist/commands/workflow/shared.d.ts +7 -0
  6. package/dist/commands/workflow/status.js +15 -2
  7. package/dist/core/archive.js +258 -78
  8. package/dist/core/artifact-graph/instruction-loader.d.ts +7 -0
  9. package/dist/core/artifact-graph/instruction-loader.js +18 -1
  10. package/dist/core/artifact-graph/outputs.d.ts +1 -1
  11. package/dist/core/artifact-graph/outputs.js +38 -8
  12. package/dist/core/change-metadata/schema.d.ts +3 -0
  13. package/dist/core/change-metadata/schema.js +10 -0
  14. package/dist/core/change-status-policy.d.ts +8 -0
  15. package/dist/core/change-status-policy.js +33 -2
  16. package/dist/core/command-generation/adapters/atomcode.d.ts +24 -0
  17. package/dist/core/command-generation/adapters/atomcode.js +46 -0
  18. package/dist/core/command-generation/adapters/codestudio.d.ts +13 -0
  19. package/dist/core/command-generation/adapters/codestudio.js +27 -0
  20. package/dist/core/command-generation/adapters/continue.js +4 -0
  21. package/dist/core/command-generation/adapters/easycode.d.ts +17 -0
  22. package/dist/core/command-generation/adapters/easycode.js +28 -0
  23. package/dist/core/command-generation/adapters/gemini.js +1 -35
  24. package/dist/core/command-generation/adapters/gigacode.d.ts +17 -0
  25. package/dist/core/command-generation/adapters/gigacode.js +31 -0
  26. package/dist/core/command-generation/adapters/index.d.ts +4 -0
  27. package/dist/core/command-generation/adapters/index.js +4 -0
  28. package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
  29. package/dist/core/command-generation/adapters/kilocode.js +2 -2
  30. package/dist/core/command-generation/registry.js +8 -0
  31. package/dist/core/command-generation/toml.d.ts +19 -0
  32. package/dist/core/command-generation/toml.js +39 -0
  33. package/dist/core/command-surface.js +1 -1
  34. package/dist/core/completions/command-registry.js +19 -0
  35. package/dist/core/completions/installers/zsh-installer.js +4 -2
  36. package/dist/core/config-prompts.js +6 -6
  37. package/dist/core/config.js +11 -1
  38. package/dist/core/init.js +44 -14
  39. package/dist/core/legacy-cleanup.d.ts +1 -1
  40. package/dist/core/legacy-cleanup.js +40 -11
  41. package/dist/core/list.d.ts +2 -0
  42. package/dist/core/list.js +49 -23
  43. package/dist/core/parsers/markdown-parser.js +5 -0
  44. package/dist/core/parsers/requirement-blocks.d.ts +40 -3
  45. package/dist/core/parsers/requirement-blocks.js +74 -25
  46. package/dist/core/project-config.js +45 -1
  47. package/dist/core/references.js +3 -1
  48. package/dist/core/root-selection.d.ts +6 -0
  49. package/dist/core/root-selection.js +16 -0
  50. package/dist/core/schemas/base.schema.d.ts +3 -0
  51. package/dist/core/schemas/base.schema.js +6 -0
  52. package/dist/core/schemas/change.schema.d.ts +8 -0
  53. package/dist/core/schemas/spec.schema.d.ts +2 -0
  54. package/dist/core/specs-apply.d.ts +20 -0
  55. package/dist/core/specs-apply.js +19 -6
  56. package/dist/core/templates/workflows/apply-change.js +5 -2
  57. package/dist/core/templates/workflows/archive-change.js +56 -20
  58. package/dist/core/templates/workflows/bulk-archive-change.js +34 -16
  59. package/dist/core/templates/workflows/continue-change.js +0 -2
  60. package/dist/core/templates/workflows/explore.js +2 -2
  61. package/dist/core/templates/workflows/ff-change.js +2 -2
  62. package/dist/core/templates/workflows/onboard.js +9 -4
  63. package/dist/core/templates/workflows/update-change.js +21 -9
  64. package/dist/core/templates/workflows/verify-change.js +115 -52
  65. package/dist/core/update.js +20 -7
  66. package/dist/core/validation/constants.d.ts +1 -1
  67. package/dist/core/validation/constants.js +1 -1
  68. package/dist/core/validation/purpose-placeholder.js +24 -9
  69. package/dist/core/validation/validator.js +15 -6
  70. package/dist/core/version-check.d.ts +23 -1
  71. package/dist/core/version-check.js +112 -26
  72. package/dist/core/view.d.ts +2 -0
  73. package/dist/core/view.js +62 -4
  74. package/dist/utils/change-metadata.d.ts +20 -1
  75. package/dist/utils/change-metadata.js +57 -1
  76. package/dist/utils/command-references.d.ts +1 -1
  77. package/dist/utils/command-references.js +2 -2
  78. package/dist/utils/file-system.js +20 -3
  79. package/dist/utils/index.d.ts +1 -1
  80. package/dist/utils/index.js +1 -1
  81. package/dist/utils/line-endings.d.ts +34 -0
  82. package/dist/utils/line-endings.js +46 -0
  83. package/package.json +2 -2
  84. package/schemas/spec-driven/schema.yaml +10 -1
  85. package/schemas/spec-driven/templates/proposal.md +6 -3
package/README.md CHANGED
@@ -122,7 +122,7 @@ Solo, OpenSpec keeps you and your AI honest on a single repo. On a team, the har
122
122
 
123
123
  ## Quick Start
124
124
 
125
- **Requires Node.js 20.19.0 or higher.**
125
+ **Requires Node.js 20.19.0 or higher.** Homebrew installs it as a dependency.
126
126
 
127
127
  Install OpenSpec globally:
128
128
 
@@ -130,6 +130,12 @@ Install OpenSpec globally:
130
130
  npm install -g @fission-ai/openspec@latest
131
131
  ```
132
132
 
133
+ Or install the official [Homebrew formula](https://formulae.brew.sh/formula/openspec) on macOS or Linux:
134
+
135
+ ```bash
136
+ brew install openspec
137
+ ```
138
+
133
139
  Then navigate to your project directory and initialize:
134
140
 
135
141
  ```bash
@@ -137,7 +143,7 @@ cd your-project
137
143
  openspec init
138
144
  ```
139
145
 
140
- > **Want your AI to do it?** Paste the [setup prompt](docs/installation.md#install-with-your-ai-assistant) into your coding assistant — it installs the CLI, runs `openspec init`, and verifies the result.
146
+ > **Want your AI to do it?** Paste the [setup prompt](docs-lab/start/installation.md#install-with-your-ai-assistant) into your coding assistant — it installs the CLI, runs `openspec init`, and verifies the result.
141
147
 
142
148
  Now talk to your AI:
143
149
 
@@ -151,7 +157,7 @@ Both are in the default profile. If you want the expanded workflow (`/opsx:new`,
151
157
  > [!NOTE]
152
158
  > Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 30+ tools and growing.
153
159
  >
154
- > Also works with pnpm, yarn, bun, and nix. [See installation options](docs/installation.md).
160
+ > Also works with Homebrew, pnpm, yarn, bun, and Nix. [See installation options](docs-lab/start/installation.md).
155
161
 
156
162
  ## Docs
157
163
 
@@ -208,6 +214,12 @@ AI coding assistants are powerful but unpredictable when requirements live only
208
214
  npm install -g @fission-ai/openspec@latest
209
215
  ```
210
216
 
217
+ If you installed OpenSpec with Homebrew:
218
+
219
+ ```bash
220
+ brew upgrade openspec
221
+ ```
222
+
211
223
  **Refresh agent instructions**
212
224
 
213
225
  Run this inside each project to regenerate AI guidance and ensure the latest slash commands are active:
package/dist/cli/index.js CHANGED
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'url';
7
7
  import { existsSync, promises as fs } from 'fs';
8
8
  import { AI_TOOLS, TOOL_ID_ALIASES } from '../core/config.js';
9
9
  import { UpdateCommand } from '../core/update.js';
10
- import { getAvailableCliUpdate, displayCliUpdateNote, shouldOfferUpgrade, getInstallDir, offerCliUpgrade, rerunUpdateWithUpgradedCli, displayUpgradeCommand, isSourceCheckout, } from '../core/version-check.js';
10
+ import { getAvailableCliUpdate, displayCliUpdateNote, shouldOfferUpgrade, getInstallDir, offerCliUpgrade, rerunUpdateWithUpgradedCli, displayUpgradeCommand, isSourceCheckout, checkForCliUpdate, getCliInstallInfo, getCliUpdateCommand, canSelfUpgrade, buildVersionReportLines, } from '../core/version-check.js';
11
11
  import { ListCommand } from '../core/list.js';
12
12
  import { ArchiveCommand } from '../core/archive.js';
13
13
  import { ViewCommand } from '../core/view.js';
@@ -118,6 +118,37 @@ program
118
118
  .version(version);
119
119
  // Global options
120
120
  program.option('--no-color', 'Disable color output');
121
+ program
122
+ .command('version')
123
+ .description('Report the installed OpenSpec version and update availability')
124
+ .option('--json', 'Output as JSON')
125
+ .option('--check', 'Check the registry for a newer version')
126
+ .action(async (options) => {
127
+ const install = getCliInstallInfo();
128
+ const update = options.check ? await checkForCliUpdate() : undefined;
129
+ const command = update?.status === 'available' ? getCliUpdateCommand(install) : null;
130
+ const output = {
131
+ schemaVersion: 1,
132
+ version,
133
+ install,
134
+ ...(update
135
+ ? {
136
+ update: {
137
+ ...update,
138
+ command,
139
+ canSelfUpgrade: update.status === 'available'
140
+ ? canSelfUpgrade(install.location, process.cwd())
141
+ : false,
142
+ },
143
+ }
144
+ : {}),
145
+ };
146
+ if (options.json) {
147
+ console.log(JSON.stringify(output, null, 2));
148
+ return;
149
+ }
150
+ console.log(buildVersionReportLines(version, install, update, command).join('\n'));
151
+ });
121
152
  // Apply global flags and telemetry before any command runs
122
153
  // Note: preAction receives (thisCommand, actionCommand) where:
123
154
  // - thisCommand: the command where hook was added (root program)
@@ -296,12 +327,17 @@ program
296
327
  .description('List items (changes by default). Use --specs to list specs.')
297
328
  .option('--specs', 'List specs instead of changes')
298
329
  .option('--changes', 'List changes explicitly (default)')
330
+ .option('--archived', 'Show only archived changes')
331
+ .option('--all', 'Show both active and archived changes')
299
332
  .option('--sort <order>', 'Sort order: "recent" (default) or "name"', 'recent')
300
333
  .option('--json', 'Output as JSON (for programmatic use)')
301
334
  .option('--store <id>', STORE_OPTION_DESCRIPTION)
302
335
  .addOption(hiddenStorePathOption())
303
336
  .action(async (options) => {
304
337
  try {
338
+ if (options?.specs && (options.archived || options.all)) {
339
+ throw new Error('--archived and --all can only be used when listing changes.');
340
+ }
305
341
  const root = await resolveRootForCommand(options ?? {}, {
306
342
  json: options?.json,
307
343
  failurePayload: options?.specs ? { specs: [], root: null } : { changes: [], root: null },
@@ -318,6 +354,8 @@ program
318
354
  await listCommand.execute(root.path, mode, {
319
355
  sort,
320
356
  json: options?.json,
357
+ archived: options?.archived,
358
+ all: options?.all,
321
359
  ...(options?.json ? { root: toRootOutput(root) } : {}),
322
360
  });
323
361
  }
@@ -45,6 +45,7 @@ function filterSpec(spec, options) {
45
45
  const filteredRequirements = (requirementIndex !== undefined
46
46
  ? [spec.requirements[requirementIndex]]
47
47
  : spec.requirements).map(req => ({
48
+ name: req.name,
48
49
  text: req.text,
49
50
  scenarios: includeScenarios ? req.scenarios : [],
50
51
  }));
@@ -7,7 +7,7 @@
7
7
  import ora from 'ora';
8
8
  import path from 'path';
9
9
  import * as fs from 'fs';
10
- import { loadChangeContext, generateInstructions, resolveSchema, resolveArtifactOutputPath, resolveArtifactOutputs, } from '../../core/artifact-graph/index.js';
10
+ import { loadChangeContext, generateInstructions, resolveSchema, resolveArtifactOutputs, } from '../../core/artifact-graph/index.js';
11
11
  import { isSpecsArtifactPath } from '../../core/artifact-graph/outputs.js';
12
12
  import { findUnreadDeltaFiles } from '../../utils/spec-discovery.js';
13
13
  import { getChangeDir, resolveCurrentPlanningHomeSync, } from '../../core/planning-home.js';
@@ -103,6 +103,14 @@ export function printInstructionsText(instructions, isBlocked) {
103
103
  ` change="${escapeEnvelopeAttribute(changeName)}"` +
104
104
  ` schema="${escapeEnvelopeAttribute(schemaName)}">`);
105
105
  console.log();
106
+ if (instructions.warnings) {
107
+ for (const warning of instructions.warnings) {
108
+ console.log('<warning>');
109
+ console.log(escapeEnvelopeTags(warning));
110
+ console.log('</warning>');
111
+ console.log();
112
+ }
113
+ }
106
114
  // Artifacts skipped via skip_specs get no creation directive: emitting the
107
115
  // task/template anyway would prompt an agent to write spec files that
108
116
  // validate then rejects as conflicting with the marker.
@@ -215,9 +223,16 @@ export function printInstructionsText(instructions, isBlocked) {
215
223
  // Closing tag
216
224
  console.log('</artifact>');
217
225
  }
218
- // -----------------------------------------------------------------------------
219
- // Apply Instructions Command
220
- // -----------------------------------------------------------------------------
226
+ /** Adds one-based source locations to parsed tasks without changing task parsing. */
227
+ function parseLocatedTasks(content, sourcePath) {
228
+ const tasks = [];
229
+ for (const [index, line] of content.split('\n').entries()) {
230
+ const [task] = parseTaskLines(line);
231
+ if (task)
232
+ tasks.push({ ...task, sourcePath, line: index + 1 });
233
+ }
234
+ return tasks;
235
+ }
221
236
  /**
222
237
  * Turns parsed task lines into the listed task items.
223
238
  *
@@ -238,6 +253,8 @@ function toTaskItems(parsed) {
238
253
  id: `${tasks.length + 1}`,
239
254
  description: task.description,
240
255
  done: task.done,
256
+ sourcePath: task.sourcePath,
257
+ line: task.line,
241
258
  });
242
259
  }
243
260
  return tasks;
@@ -394,15 +411,28 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
394
411
  contextFiles[artifact.id] = outputs;
395
412
  }
396
413
  }
397
- // Parse tasks if tracking file exists
414
+ // Parse every concrete file matched by apply.tracks. A tracking path may be
415
+ // a glob owned by an artifact with any ID, so treating it as one literal
416
+ // path loses task evidence for valid custom schemas.
398
417
  let parsedTasks = [];
418
+ const unavailableTrackingFiles = [];
399
419
  let tracksFileExists = false;
400
420
  if (tracksFile) {
401
- const tracksPath = resolveArtifactOutputPath(changeDir, tracksFile);
402
- tracksFileExists = fs.existsSync(tracksPath);
403
- if (tracksFileExists) {
404
- const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8');
405
- parsedTasks = parseTaskLines(tasksContent);
421
+ const tracksPaths = resolveArtifactOutputs(changeDir, tracksFile);
422
+ tracksFileExists = tracksPaths.length > 0;
423
+ for (const tracksPath of tracksPaths) {
424
+ try {
425
+ const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8');
426
+ parsedTasks.push(...parseLocatedTasks(tasksContent, tracksPath));
427
+ }
428
+ catch (error) {
429
+ const code = error?.code;
430
+ const message = error instanceof Error ? error.message : String(error);
431
+ unavailableTrackingFiles.push({
432
+ path: tracksPath,
433
+ reason: code && !message.includes(code) ? `${code}: ${message}` : message,
434
+ });
435
+ }
406
436
  }
407
437
  }
408
438
  const tasks = toTaskItems(parsedTasks);
@@ -435,6 +465,10 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
435
465
  `The ${tracksFilename} file is missing and must be created.` +
436
466
  `\n${describeArtifactRemedy(changeName, findArtifactIdFor(schema, tracksFile))}`;
437
467
  }
468
+ else if (tracksFile && unavailableTrackingFiles.length > 0 && tasks.length === 0) {
469
+ state = 'blocked';
470
+ instruction = 'No readable task descriptions are available.';
471
+ }
438
472
  else if (tracksFile && tracksFileExists && tasks.length === 0) {
439
473
  // Tracking file exists but lists nothing an agent can work on: either no
440
474
  // checkboxes at all, or only checkboxes with no text after them.
@@ -444,7 +478,10 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
444
478
  `The ${tracksFilename} file exists but contains no tasks to work on.` +
445
479
  `\nAdd tasks to ${tracksFilename}, or rebuild it: ${describeArtifactRemedy(changeName, findArtifactIdFor(schema, tracksFile))}`;
446
480
  }
447
- else if (tracksFile && remaining === 0 && total > 0) {
481
+ else if (tracksFile &&
482
+ unavailableTrackingFiles.length === 0 &&
483
+ remaining === 0 &&
484
+ total > 0) {
448
485
  state = 'all_done';
449
486
  instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.';
450
487
  }
@@ -457,13 +494,22 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
457
494
  state = 'ready';
458
495
  instruction = schemaInstruction?.trim() ?? 'Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.';
459
496
  }
460
- const warnings = await collectApplyWarnings({
461
- state,
462
- schema,
463
- changeDir,
464
- changeName,
465
- skippedArtifacts: context.skippedArtifacts,
466
- });
497
+ if (unavailableTrackingFiles.length > 0) {
498
+ const unavailableDetails = unavailableTrackingFiles
499
+ .map((file) => `- ${file.path}: ${file.reason}`)
500
+ .join('\n');
501
+ instruction += `\nTask completion is not verified because tracking evidence was unavailable:\n${unavailableDetails}`;
502
+ }
503
+ const warnings = [
504
+ ...(context.warnings ?? []),
505
+ ...(await collectApplyWarnings({
506
+ state,
507
+ schema,
508
+ changeDir,
509
+ changeName,
510
+ skippedArtifacts: context.skippedArtifacts,
511
+ })),
512
+ ];
467
513
  return {
468
514
  changeName,
469
515
  changeDir,
@@ -471,6 +517,8 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
471
517
  contextFiles,
472
518
  progress: { total, complete, remaining },
473
519
  tasks,
520
+ taskTrackingConfigured: tracksFile !== null,
521
+ ...(unavailableTrackingFiles.length > 0 ? { unavailableTrackingFiles } : {}),
474
522
  state,
475
523
  missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : undefined,
476
524
  ...(missingPrerequisites.length > 0 ? { missingPrerequisites } : {}),
@@ -16,6 +16,8 @@ export interface TaskItem {
16
16
  id: string;
17
17
  description: string;
18
18
  done: boolean;
19
+ sourcePath: string;
20
+ line: number;
19
21
  }
20
22
  export interface ApplyInstructions {
21
23
  changeName: string;
@@ -28,6 +30,11 @@ export interface ApplyInstructions {
28
30
  remaining: number;
29
31
  };
30
32
  tasks: TaskItem[];
33
+ taskTrackingConfigured: boolean;
34
+ unavailableTrackingFiles?: Array<{
35
+ path: string;
36
+ reason: string;
37
+ }>;
31
38
  state: 'blocked' | 'all_done' | 'ready';
32
39
  missingArtifacts?: string[];
33
40
  /**
@@ -6,7 +6,7 @@
6
6
  import ora from 'ora';
7
7
  import chalk from 'chalk';
8
8
  import { getChangeDir } from '../../core/planning-home.js';
9
- import { resolveRootForCommand, toPlanningHome, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from '../../core/root-selection.js';
9
+ import { resolveRootForCommand, toPlanningHome, toRootOutput, withStoreFlag, isStoreSelectedRoot, findDeclaringProjectRoot, } from '../../core/root-selection.js';
10
10
  import { loadChangeContext, formatChangeStatus, } from '../../core/artifact-graph/index.js';
11
11
  import { resolveNextStep } from '../../core/change-status-policy.js';
12
12
  import { asStatus } from '../shared-output.js';
@@ -43,12 +43,20 @@ export async function statusCommand(options) {
43
43
  // `Next:` line, so a store-selected root can never carry `--store` in one
44
44
  // and drop it from the other.
45
45
  const storeOptions = isStoreSelectedRoot(root) ? { storeId: root.storeId } : {};
46
+ // A store holds planning artifacts only; the project declaring it is
47
+ // where implementation edits go (#2013).
48
+ const implementationRoot = isStoreSelectedRoot(root)
49
+ ? findDeclaringProjectRoot(root.storeId)
50
+ : null;
51
+ const statusOptions = implementationRoot
52
+ ? { ...storeOptions, implementationRoot }
53
+ : storeOptions;
46
54
  // Single definition of "load one change's status" so the batch and
47
55
  // single-change payloads can never drift apart.
48
56
  const loadStatus = (changeName) => formatChangeStatus(loadChangeContext(projectRoot, changeName, options.schema, {
49
57
  changeDir: getChangeDir(planningHome, changeName),
50
58
  planningHome,
51
- }), storeOptions);
59
+ }), statusOptions);
52
60
  // Handle no-changes case gracefully — status is informational,
53
61
  // so "no changes" is a valid state, not an error.
54
62
  if (!options.change) {
@@ -157,6 +165,11 @@ export function printStatusText(status, options = {}) {
157
165
  const total = status.artifacts.length - skippedCount;
158
166
  console.log(`Change: ${status.changeName}`);
159
167
  console.log(`Schema: ${status.schemaName}`);
168
+ if (status.warnings) {
169
+ for (const warning of status.warnings) {
170
+ console.log(chalk.yellow(`Warning: ${warning}`));
171
+ }
172
+ }
160
173
  if (status.changeRoot) {
161
174
  console.log(`Change root: ${status.changeRoot}`);
162
175
  }