@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
@@ -19,29 +19,37 @@ export const OPENSPEC_MARKERS = {
19
19
  };
20
20
  export const AI_TOOLS = [
21
21
  { name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq', requiresIdeRestart: true },
22
+ { name: 'Amp', value: 'amp', available: true, successLabel: 'Amp', skillsDir: '.agents', detectionPaths: ['.amp', '.agents/skills'] },
22
23
  // Antigravity moved workspace skills and workflows from `.agent` to the
23
24
  // shared `.agents` root in v1.20.5. Detection keys off `.agent` and
24
25
  // `.agents/workflows` rather than the bare `.agents` root: that root is
25
26
  // shared with Codex, Zed, and the vendor-neutral target, so its presence
26
27
  // alone says nothing about Antigravity.
27
28
  { name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agents', legacySkillsDirs: ['.agent'], detectionPaths: ['.agent', '.agents/workflows'], requiresIdeRestart: true },
29
+ { name: 'AtomCode', value: 'atomcode', available: true, successLabel: 'AtomCode', skillsDir: '.atomcode' },
28
30
  { name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment' },
29
- { name: 'Bob Shell', value: 'bob', available: true, successLabel: 'Bob Shell', skillsDir: '.bob' },
31
+ { name: 'IBM Bob', value: 'bob', available: true, successLabel: 'IBM Bob', skillsDir: '.bob' },
30
32
  { name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' },
31
33
  { name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline', requiresIdeRestart: true },
32
34
  { name: 'Command Code', value: 'command-code', available: true, successLabel: 'Command Code', skillsDir: '.commandcode' },
33
35
  { name: 'CodeArts', value: 'codeartsagent', available: true, successLabel: 'CodeArts', skillsDir: '.codeartsdoer' },
34
36
  { name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.agents', legacySkillsDirs: ['.codex'], detectionPaths: ['.agents/skills', '.codex/skills'] },
37
+ { name: 'DeepSeek Harness', value: 'dsh', available: true, successLabel: 'DeepSeek Harness', skillsDir: '.dsh' },
35
38
  { name: 'Devin Desktop (formerly Windsurf)', value: 'devin', available: true, successLabel: 'Devin Desktop', skillsDir: '.devin', detectionPaths: ['.devin', '.windsurf'], requiresIdeRestart: true },
36
39
  { name: 'ForgeCode', value: 'forgecode', available: true, successLabel: 'ForgeCode', skillsDir: '.forge' },
37
40
  { name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code', skillsDir: '.codebuddy' },
41
+ { name: 'Code Studio', value: 'codestudio', available: true, successLabel: 'Code Studio', skillsDir: '.codestudio', requiresIdeRestart: true },
38
42
  { name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue', requiresIdeRestart: true },
39
43
  { name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec', requiresIdeRestart: true },
40
44
  { name: 'Crush', value: 'crush', available: true, successLabel: 'Crush', skillsDir: '.crush' },
41
45
  { name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor', requiresIdeRestart: true },
46
+ { name: 'EasyCode', value: 'easycode', available: true, successLabel: 'EasyCode', skillsDir: '.easycode' },
42
47
  { name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' },
43
48
  { name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' },
44
49
  { 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 },
50
+ { name: 'GigaCode', value: 'gigacode', available: true, successLabel: 'GigaCode', skillsDir: '.gigacode' },
51
+ { name: 'Grok Build', value: 'grok', available: true, successLabel: 'Grok Build', skillsDir: '.grok' },
52
+ { name: 'GSD', value: 'gsd', available: true, successLabel: 'GSD', skillsDir: '.agents', detectionPaths: ['.gsd'] },
45
53
  { 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." },
46
54
  { name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },
47
55
  { name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie', requiresIdeRestart: true },
@@ -60,6 +68,8 @@ export const AI_TOOLS = [
60
68
  { name: 'Rovo Dev CLI', value: 'rovodev', available: true, successLabel: 'Rovo Dev CLI', skillsDir: '.rovodev', detectionPaths: ['.rovodev/skills', '.rovodev'] },
61
69
  { name: 'Zoo Code', value: 'roocode', available: true, successLabel: 'Zoo Code', skillsDir: '.roo', requiresIdeRestart: true },
62
70
  { name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae', requiresIdeRestart: true },
71
+ { name: 'Veai', value: 'veai', available: true, successLabel: 'Veai', skillsDir: '.veai' },
72
+ { name: 'Warp', value: 'warp', available: true, successLabel: 'Warp', skillsDir: '.warp', detectionPaths: ['.warp', 'WARP.md'] },
63
73
  { name: 'Zed Agent', value: 'zed', available: true, successLabel: 'Zed Agent', skillsDir: '.agents', detectionPaths: ['.zed', '.agents/skills'] },
64
74
  { name: 'ZCode', value: 'zcode', available: true, successLabel: 'ZCode', skillsDir: '.zcode' },
65
75
  // Vendor-neutral target for assistants that read the shared `.agents` root.
package/dist/core/init.js CHANGED
@@ -12,6 +12,7 @@ import { createRequire } from 'module';
12
12
  import { FileSystemUtils } from '../utils/file-system.js';
13
13
  import { classifyOpenSpecDir, MAX_CONTEXT_SIZE, readProjectConfig, storePointerProblem, } from './project-config.js';
14
14
  import { findRepoPlanningRootSync } from './planning-home.js';
15
+ import { resolveOpenSpecRoot } from './root-selection.js';
15
16
  import { ANCHORED_OPENSPEC_DIRS, ensureDirectoryAnchor } from './openspec-root.js';
16
17
  import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js';
17
18
  import { AI_TOOLS, getUniversalTool, universalToolFallbackHint, OPENSPEC_DIR_NAME, resolveToolIdAlias, } from './config.js';
@@ -81,7 +82,7 @@ export class InitCommand {
81
82
  this.copilotCloudOption = options.copilotCloud;
82
83
  }
83
84
  async execute(targetPath) {
84
- const projectPath = path.resolve(targetPath);
85
+ const projectPath = FileSystemUtils.canonicalizeExistingPath(targetPath);
85
86
  const openspecDir = OPENSPEC_DIR_NAME;
86
87
  const openspecPath = path.join(projectPath, openspecDir);
87
88
  // Validation happens silently in the background
@@ -94,6 +95,7 @@ export class InitCommand {
94
95
  // finds the nearest ancestor root (so pointer-repo subdirectories
95
96
  // refuse exactly where a normal command would resolve the pointer).
96
97
  const guardRoot = findRepoPlanningRootSync(projectPath);
98
+ let integrationsOnly = false;
97
99
  if (guardRoot) {
98
100
  const { hasPlanningShape, pointer } = classifyOpenSpecDir(guardRoot);
99
101
  if (!hasPlanningShape) {
@@ -103,14 +105,30 @@ export class InitCommand {
103
105
  `). Fix or remove the store: line before running openspec init.`);
104
106
  }
105
107
  if (pointer.value !== undefined) {
106
- throw new Error(`This repo's planning is externalized to store '${pointer.value}' (${pointer.filePath}). ` +
107
- `Remove the store: line first to convert this repo to a local OpenSpec root.`);
108
+ if (path.resolve(guardRoot) !== projectPath) {
109
+ throw new Error(`This repo's planning is externalized to store '${pointer.value}' (${pointer.filePath}). ` +
110
+ 'Run openspec init from the pointer repo root to install integrations.');
111
+ }
112
+ // A valid pointer repo already has its planning root in the declared
113
+ // store. Init should still be able to install agent integrations in
114
+ // the code repo, without creating a second local planning root.
115
+ await resolveOpenSpecRoot({ startPath: projectPath });
116
+ integrationsOnly = true;
108
117
  }
109
118
  }
110
119
  }
111
- await this.assertLanguageCanBeApplied(projectPath, openspecPath);
112
- // Check for legacy artifacts and handle cleanup
113
- const deferredLegacyCleanup = await this.handleLegacyCleanup(projectPath, extendMode);
120
+ if (!integrationsOnly) {
121
+ await this.assertLanguageCanBeApplied(projectPath, openspecPath);
122
+ }
123
+ else if (this.language) {
124
+ throw new Error('--language cannot update an external store through a pointer repo. ' +
125
+ 'Run init in the store root, or edit the store config directly.');
126
+ }
127
+ // Pointer repos keep their local planning files untouched. Normal init may
128
+ // still remove OpenSpec-managed artifacts from older layouts.
129
+ const deferredLegacyCleanup = integrationsOnly
130
+ ? null
131
+ : await this.handleLegacyCleanup(projectPath, extendMode);
114
132
  // Migrate OpenSpec-managed skills left in renamed tool directories
115
133
  // (e.g. .kimi -> .kimi-code) before detection so they stay recognized.
116
134
  migrateLegacyToolDirs(projectPath);
@@ -154,8 +172,11 @@ export class InitCommand {
154
172
  // is made before generation so the write can be gated, and persisted after
155
173
  // config.yaml exists so future non-interactive updates honor it.
156
174
  const copilotDecision = await this.resolveCopilotCloudDecision(projectPath, validatedTools);
157
- // Create directory structure and config
158
- await this.createDirectoryStructure(openspecPath, extendMode);
175
+ // Pointer repos only receive integrations. Their planning structure and
176
+ // config stay in the declared store.
177
+ if (!integrationsOnly) {
178
+ await this.createDirectoryStructure(openspecPath, extendMode);
179
+ }
159
180
  // Generate skills and commands for each tool
160
181
  const results = await this.generateSkillsAndCommands(projectPath, validatedTools, copilotDecision.write);
161
182
  // Legacy cleanup was deferred to avoid interfering with skill/command generation;
@@ -163,12 +184,15 @@ export class InitCommand {
163
184
  if (deferredLegacyCleanup) {
164
185
  await this.finalizeDeferredLegacyCleanup(projectPath, deferredLegacyCleanup);
165
186
  }
166
- // Create config.yaml if needed
167
- const configStatus = await this.createConfig(openspecPath, extendMode);
187
+ // Create config.yaml if needed. A pointer repo already has the config that
188
+ // declares its store, so preserve it byte-for-byte.
189
+ const configStatus = integrationsOnly
190
+ ? 'exists'
191
+ : await this.createConfig(openspecPath, extendMode);
168
192
  // Persist an explicit Copilot cloud decision so `openspec update` (which
169
193
  // never prompts) honors it. Best-effort: a config-write failure must not
170
194
  // fail an otherwise-successful init.
171
- if (copilotDecision.persist !== undefined) {
195
+ if (!integrationsOnly && copilotDecision.persist !== undefined) {
172
196
  try {
173
197
  await persistCopilotCloudOptIn(projectPath, copilotDecision.persist);
174
198
  }
@@ -992,9 +1016,15 @@ export class InitCommand {
992
1016
  // Tools with no slash surface (e.g. Rovo Dev) reference skills as
993
1017
  // prose ("the openspec-propose skill"); phrase the hint so it reads
994
1018
  // as an instruction rather than a dead command with an argument.
995
- hint = usesNaturalLanguageSkillReferences(tool.value)
996
- ? `Start your first change: ask ${tool.name} to use ${skillReference} with "your idea"`
997
- : `Start your first change: ${skillReference} "your idea"`;
1019
+ if (usesNaturalLanguageSkillReferences(tool.value)) {
1020
+ hint = `Start your first change: ask ${tool.name} to use ${skillReference} with "your idea"`;
1021
+ }
1022
+ else if (tool.value === 'codex') {
1023
+ hint = `Start your first change: ${skillReference} "your idea" (Codex CLI or IDE); in the Codex desktop app, select ${skillReference.slice(1)} from Skills in the sidebar`;
1024
+ }
1025
+ else {
1026
+ hint = `Start your first change: ${skillReference} "your idea"`;
1027
+ }
998
1028
  }
999
1029
  else {
1000
1030
  continue;
@@ -2,7 +2,7 @@
2
2
  * Legacy cleanup module for detecting and removing OpenSpec artifacts
3
3
  * from previous init versions during the migration to the skill-based workflow.
4
4
  */
5
- import type { WorkflowId } from './profiles.js';
5
+ import { type WorkflowId } from './profiles.js';
6
6
  /**
7
7
  * Legacy config file names from the old ToolRegistry.
8
8
  * These were config files created at project root with OpenSpec markers.
@@ -4,10 +4,11 @@
4
4
  */
5
5
  import path from 'path';
6
6
  import os from 'os';
7
- import { promises as fs } from 'fs';
7
+ import { promises as fs, constants as fsConstants } from 'fs';
8
8
  import chalk from 'chalk';
9
9
  import { FileSystemUtils, removeMarkerBlock as removeMarkerBlockUtil } from '../utils/file-system.js';
10
10
  import { OPENSPEC_MARKERS } from './config.js';
11
+ import { ALL_WORKFLOWS } from './profiles.js';
11
12
  /**
12
13
  * Legacy config file names from the old ToolRegistry.
13
14
  * These were config files created at project root with OpenSpec markers.
@@ -24,6 +25,13 @@ export const LEGACY_CONFIG_FILES = [
24
25
  ];
25
26
  /** The three commands the old SlashCommandRegistry wrote into each directory. */
26
27
  const LEGACY_DIRECTORY_COMMAND_FILES = ['proposal.md', 'apply.md', 'archive.md'];
28
+ /** Exact Kilo workflow files written by OpenSpec before the command path moved. */
29
+ const LEGACY_KILOCODE_COMMAND_FILES = [
30
+ ...ALL_WORKFLOWS.map(workflow => `.kilocode/workflows/opsx-${workflow}.md`),
31
+ '.kilocode/workflows/openspec-proposal.md',
32
+ '.kilocode/workflows/openspec-apply.md',
33
+ '.kilocode/workflows/openspec-archive.md',
34
+ ];
27
35
  /**
28
36
  * Legacy slash command patterns from the old SlashCommandRegistry.
29
37
  * These map toolId to the path pattern where legacy commands were created.
@@ -48,7 +56,12 @@ export const LEGACY_SLASH_COMMAND_PATHS = {
48
56
  // belong to `devin` — the id Windsurf became. Only `.windsurf/` is listed:
49
57
  // `.devin/` postdates the opsx rename and never held `openspec-*` files.
50
58
  'devin': { type: 'files', pattern: '.windsurf/workflows/openspec-*.md' },
51
- 'kilocode': { type: 'files', pattern: '.kilocode/workflows/openspec-*.md' },
59
+ // Kilo now writes commands under `.kilo/command/`. Clean up both generations
60
+ // of OpenSpec workflows from Kilo's legacy `.kilocode/workflows/` folder.
61
+ 'kilocode': {
62
+ type: 'files',
63
+ pattern: LEGACY_KILOCODE_COMMAND_FILES,
64
+ },
52
65
  'kiro': { type: 'files', pattern: '.kiro/prompts/openspec-*.prompt.md' },
53
66
  'github-copilot': { type: 'files', pattern: '.github/prompts/openspec-*.prompt.md' },
54
67
  'amazon-q': { type: 'files', pattern: '.amazonq/prompts/openspec-*.md' },
@@ -331,15 +344,27 @@ async function settleLegacyCommandDir(projectPath, dirPath, result) {
331
344
  * a same-named file without them is the user's.
332
345
  */
333
346
  async function isGeneratedLegacyCommand(filePath) {
347
+ // Judge the opened handle, not the path, so the file checked is the file
348
+ // read. O_NOFOLLOW refuses a link and O_NONBLOCK keeps a FIFO from hanging;
349
+ // Windows has neither flag, so a link is refused there by lstat instead.
350
+ const { O_RDONLY, O_NOFOLLOW, O_NONBLOCK } = fsConstants;
351
+ let handle;
334
352
  try {
335
- if (!(await fs.lstat(filePath)).isFile()) {
353
+ handle = await fs.open(filePath, O_RDONLY | (O_NOFOLLOW ?? 0) | (O_NONBLOCK ?? 0));
354
+ if (O_NOFOLLOW === undefined && (await fs.lstat(filePath)).isSymbolicLink()) {
355
+ return false;
356
+ }
357
+ if (!(await handle.stat()).isFile()) {
336
358
  return false;
337
359
  }
338
- return hasOpenSpecMarkers(await fs.readFile(filePath, 'utf-8'));
360
+ return hasOpenSpecMarkers(await handle.readFile('utf-8'));
339
361
  }
340
362
  catch {
341
363
  return false;
342
364
  }
365
+ finally {
366
+ await handle?.close();
367
+ }
343
368
  }
344
369
  /**
345
370
  * Detects legacy global slash command files.
@@ -720,10 +745,10 @@ export function formatDetectionSummary(detection) {
720
745
  lines.push('agents. This simplifies your setup while keeping everything working');
721
746
  lines.push('as before.');
722
747
  lines.push('');
723
- // Section 1: Files to remove (no user content to preserve)
748
+ // Section 1: Files to remove entirely
724
749
  if (removals.length > 0) {
725
750
  lines.push(chalk.bold('Files to remove'));
726
- lines.push(chalk.dim('No user content to preserve:'));
751
+ lines.push(chalk.dim('These files will be deleted entirely. Back up any custom content before proceeding:'));
727
752
  for (const { path } of removals) {
728
753
  lines.push(` • ${path}`);
729
754
  }
@@ -928,12 +953,16 @@ export function formatProjectMdMigrationHint() {
928
953
  lines.push(' • openspec/project.md');
929
954
  lines.push(chalk.dim(' We won\'t delete this file. It may contain useful project context.'));
930
955
  lines.push('');
931
- lines.push(chalk.dim(' The new openspec/config.yaml has a "context:" section for planning'));
932
- lines.push(chalk.dim(' context. This is included in every OpenSpec request and works more'));
933
- lines.push(chalk.dim(' reliably than the old project.md approach.'));
956
+ lines.push(chalk.dim(' Ask your AI assistant:'));
957
+ lines.push('');
958
+ lines.push(chalk.dim(' Review openspec/project.md and migrate its useful content to'));
959
+ lines.push(chalk.dim(' openspec/config.yaml. Keep context concise: include only project-wide'));
960
+ lines.push(chalk.dim(' facts needed during artifact creation, apply, and archive. Move'));
961
+ lines.push(chalk.dim(' artifact-specific guidance into rules for the matching artifacts.'));
962
+ lines.push(chalk.dim(' Move guidance for apply or archive into the matching operations entry.'));
963
+ lines.push(chalk.dim(' Leave out generic, outdated, or verbose material. Do not delete project.md.'));
934
964
  lines.push('');
935
- lines.push(chalk.dim(' Review project.md, move any useful content to config.yaml\'s context'));
936
- lines.push(chalk.dim(' section, then delete the file when ready.'));
965
+ lines.push(chalk.dim(' Review config.yaml, then delete project.md when ready.'));
937
966
  return lines.join('\n');
938
967
  }
939
968
  //# sourceMappingURL=legacy-cleanup.js.map
@@ -3,6 +3,8 @@ interface ListOptions {
3
3
  sort?: 'recent' | 'name';
4
4
  json?: boolean;
5
5
  root?: RootOutput;
6
+ archived?: boolean;
7
+ all?: boolean;
6
8
  }
7
9
  export declare class ListCommand {
8
10
  execute(targetPath?: string, mode?: 'changes' | 'specs', options?: ListOptions): Promise<void>;
package/dist/core/list.js CHANGED
@@ -35,8 +35,9 @@ async function readChangeDirectoryEntries(changesDir) {
35
35
  /**
36
36
  * Get the most recent modification time of any file in a directory (recursive).
37
37
  * Falls back to the directory's own mtime if no files are found.
38
+ * Archived links use their own mtime: moving a change can break relative targets.
38
39
  */
39
- async function getLastModified(dirPath) {
40
+ async function getLastModified(dirPath, archived = false) {
40
41
  let latest = null;
41
42
  async function walk(dir) {
42
43
  const entries = await fs.readdir(dir, { withFileTypes: true });
@@ -47,7 +48,9 @@ async function getLastModified(dirPath) {
47
48
  await walk(fullPath);
48
49
  }
49
50
  else {
50
- const stat = await fs.stat(fullPath);
51
+ const stat = archived && entry.isSymbolicLink()
52
+ ? await fs.lstat(fullPath)
53
+ : await fs.stat(fullPath);
51
54
  if (latest === null || stat.mtime > latest) {
52
55
  latest = stat.mtime;
53
56
  }
@@ -96,20 +99,31 @@ function formatRelativeTime(date) {
96
99
  }
97
100
  export class ListCommand {
98
101
  async execute(targetPath = '.', mode = 'changes', options = {}) {
99
- const { sort = 'recent', json = false, root } = options;
102
+ const { sort = 'recent', json = false, root, archived = false, all = false } = options;
103
+ if (mode === 'specs' && (archived || all)) {
104
+ throw new Error('--archived and --all can only be used when listing changes.');
105
+ }
100
106
  if (mode === 'changes') {
101
107
  const changesDir = path.join(targetPath, 'openspec', 'changes');
102
- // Get all directories in changes (excluding archive)
108
+ const archiveDir = path.join(changesDir, 'archive');
109
+ const includeArchived = archived || all;
110
+ // Read the parent even for --archived: Windows can report ENOENT for
111
+ // changes/archive when changes is a file, hiding a malformed root.
103
112
  const entries = await readChangeDirectoryEntries(changesDir);
104
- const changeDirs = entries
113
+ const activeDirs = !archived || all ? entries
105
114
  .filter(entry => entry.isDirectory() && entry.name !== 'archive')
106
- .map(entry => entry.name);
115
+ .map(entry => ({ name: entry.name, parent: changesDir, archived: false })) : [];
116
+ const archiveEntries = includeArchived ? await readChangeDirectoryEntries(archiveDir) : [];
117
+ const archivedDirs = archiveEntries
118
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
119
+ .map(entry => ({ name: entry.name, parent: archiveDir, archived: true }));
120
+ const changeDirs = [...activeDirs, ...archivedDirs];
107
121
  if (changeDirs.length === 0) {
108
122
  if (json) {
109
123
  console.log(JSON.stringify({ changes: [], ...(root ? { root } : {}) }, null, 2));
110
124
  }
111
125
  else {
112
- console.log('No active changes found.');
126
+ console.log(all ? 'No changes found.' : archived ? 'No archived changes found.' : 'No active changes found.');
113
127
  }
114
128
  return;
115
129
  }
@@ -118,18 +132,21 @@ export class ListCommand {
118
132
  // A directory that only wraps nested change directories is still listed -
119
133
  // hiding it would hide a real change whenever the probe is wrong - but it
120
134
  // is listed as what it is, so the nesting stops failing silently (#1846).
121
- const nestedFindings = await findNestedChanges(changesDir, changeDirs);
135
+ const nestedFindings = await findNestedChanges(changesDir, activeDirs.map((changeDir) => changeDir.name));
122
136
  const nestedByName = new Map(nestedFindings.map((finding) => [finding.name, finding]));
123
137
  for (const changeDir of changeDirs) {
124
- const progress = await getTaskProgressForChange(changesDir, changeDir, targetPath);
125
- const changePath = path.join(changesDir, changeDir);
126
- const lastModified = await getLastModified(changePath);
138
+ const progress = await getTaskProgressForChange(changeDir.parent, changeDir.name, targetPath);
139
+ const changePath = path.join(changeDir.parent, changeDir.name);
140
+ const lastModified = await getLastModified(changePath, changeDir.archived);
127
141
  changes.push({
128
- name: changeDir,
142
+ name: changeDir.name,
129
143
  completedTasks: progress.completed,
130
144
  totalTasks: progress.total,
131
145
  lastModified,
132
- ...(nestedByName.has(changeDir) ? { nested: nestedByName.get(changeDir).nested } : {})
146
+ archived: changeDir.archived,
147
+ ...(!changeDir.archived && nestedByName.has(changeDir.name)
148
+ ? { nested: nestedByName.get(changeDir.name).nested }
149
+ : {})
133
150
  });
134
151
  }
135
152
  // Sort by preference (default: recent first)
@@ -147,6 +164,7 @@ export class ListCommand {
147
164
  totalTasks: c.totalTasks,
148
165
  lastModified: c.lastModified.toISOString(),
149
166
  status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress',
167
+ ...(includeArchived ? { archived: c.archived } : {}),
150
168
  ...(c.nested ? { nested: c.nested } : {})
151
169
  }));
152
170
  // Additive: the entries keep their shape so existing consumers are
@@ -165,16 +183,24 @@ export class ListCommand {
165
183
  return;
166
184
  }
167
185
  // Display results
168
- console.log('Changes:');
169
- const padding = ' ';
170
- const nameWidth = Math.max(...changes.map(c => c.name.length));
171
- for (const change of changes) {
172
- const paddedName = change.name.padEnd(nameWidth);
173
- const status = change.nested
174
- ? 'not a change'
175
- : formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
176
- const timeAgo = formatRelativeTime(change.lastModified);
177
- console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`);
186
+ const groups = [
187
+ { heading: 'Changes:', changes: changes.filter(change => !change.archived) },
188
+ { heading: 'Archived Changes:', changes: changes.filter(change => change.archived) }
189
+ ].filter(group => group.changes.length > 0);
190
+ for (const [index, group] of groups.entries()) {
191
+ if (index > 0)
192
+ console.log('');
193
+ console.log(group.heading);
194
+ const padding = ' ';
195
+ const nameWidth = Math.max(...group.changes.map(c => c.name.length));
196
+ for (const change of group.changes) {
197
+ const paddedName = change.name.padEnd(nameWidth);
198
+ const status = change.nested
199
+ ? 'not a change'
200
+ : formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
201
+ const timeAgo = formatRelativeTime(change.lastModified);
202
+ console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`);
203
+ }
178
204
  }
179
205
  for (const finding of nestedFindings) {
180
206
  console.log('');
@@ -1,4 +1,5 @@
1
1
  import { buildCodeFenceMask, extractRequirementText, hasScenarioBody } from './requirement-text.js';
2
+ import { normalizeRequirementName, scenarioNameFromHeaderText } from './requirement-blocks.js';
2
3
  export class MarkdownParser {
3
4
  lines;
4
5
  codeFenceLineMask;
@@ -121,6 +122,9 @@ export class MarkdownParser {
121
122
  const text = extractRequirementText(child.title, child.content.split('\n'));
122
123
  const scenarios = this.parseScenarios(child);
123
124
  requirements.push({
125
+ // The name archive matches on, so a JSON reader can cite a requirement
126
+ // the way a MODIFIED or REMOVED header must.
127
+ name: normalizeRequirementName(child.title.replace(/^Requirement:\s*/i, '')),
124
128
  text,
125
129
  scenarios,
126
130
  });
@@ -134,6 +138,7 @@ export class MarkdownParser {
134
138
  // body is not a scenario; the delta counter applies the same rule.
135
139
  if (hasScenarioBody(scenarioSection.content)) {
136
140
  scenarios.push({
141
+ name: scenarioNameFromHeaderText(scenarioSection.title),
137
142
  rawText: scenarioSection.content
138
143
  });
139
144
  }
@@ -95,14 +95,51 @@ export interface DeltaPlan {
95
95
  * Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
96
96
  */
97
97
  export declare function parseDeltaSpec(content: string): DeltaPlan;
98
+ /** Both directions of the scenario-name comparison, plus the two totals. */
99
+ export interface ScenarioNameDiff {
100
+ /** Names the current block has that the incoming block does not cover. */
101
+ missing: string[];
102
+ /** Names the incoming block introduces that the current block does not have. */
103
+ added: string[];
104
+ /** Level-4 headers in the current block. */
105
+ currentCount: number;
106
+ /** Level-4 headers in the incoming block. */
107
+ incomingCount: number;
108
+ }
109
+ /**
110
+ * Compare the scenario names of a current requirement block and an incoming
111
+ * (MODIFIED) one, in both directions.
112
+ *
113
+ * `missing` is the loss the guard exists to catch: a MODIFIED requirement
114
+ * replaces the whole block, so every name there would be dropped from the main
115
+ * spec. `added` and the two counts are reported alongside it, because they are
116
+ * the first thing a reader checks once it fires (#1697) - a block that omits
117
+ * two names and introduces two is shaped like a rename, one that omits two and
118
+ * introduces none is shaped like a truncation. Neither is proof, and intent is
119
+ * not recoverable from structure, so this decides nothing and only says what
120
+ * the two blocks contain.
121
+ */
122
+ export declare function diffScenarioNames(current: RequirementBlock, incoming: RequirementBlock): ScenarioNameDiff;
98
123
  /**
99
124
  * Scenario names the current requirement block has and the incoming
100
125
  * (MODIFIED) block does not. A MODIFIED requirement replaces the whole block,
101
126
  * so every name reported here would be dropped from the main spec.
102
127
  *
103
- * Shared by archive (which refuses to apply the block) and validate (which
104
- * reports the same loss at authoring time, #1477), so the two cannot disagree
105
- * about what counts as a dropped scenario.
128
+ * The `missing` half of diffScenarioNames, which archive (refusing to apply
129
+ * the block) and validate (reporting the same loss at authoring time, #1477)
130
+ * both go through, so the two cannot disagree about what counts as a dropped
131
+ * scenario.
106
132
  */
107
133
  export declare function findMissingCurrentScenarios(current: RequirementBlock, incoming: RequirementBlock): string[];
134
+ /**
135
+ * The one sentence archive and validate both append when the guard fires, so
136
+ * the counts a reader sees cannot differ between the two commands.
137
+ */
138
+ export declare function describeScenarioBalance(diff: ScenarioNameDiff): string;
139
+ /**
140
+ * scenarioNameAt for header text whose leading `####` is already gone, as the
141
+ * section parser (MarkdownParser) holds it, so `show --json` names a scenario
142
+ * exactly as the MODIFIED loss check does.
143
+ */
144
+ export declare function scenarioNameFromHeaderText(headerText: string): string;
108
145
  //# sourceMappingURL=requirement-blocks.d.ts.map
@@ -380,37 +380,79 @@ function parseRenamedPairs(sectionBody, unpaired) {
380
380
  drop('FROM', pending.name, pending.line);
381
381
  return pairs;
382
382
  }
383
+ /**
384
+ * Compare the scenario names of a current requirement block and an incoming
385
+ * (MODIFIED) one, in both directions.
386
+ *
387
+ * `missing` is the loss the guard exists to catch: a MODIFIED requirement
388
+ * replaces the whole block, so every name there would be dropped from the main
389
+ * spec. `added` and the two counts are reported alongside it, because they are
390
+ * the first thing a reader checks once it fires (#1697) - a block that omits
391
+ * two names and introduces two is shaped like a rename, one that omits two and
392
+ * introduces none is shaped like a truncation. Neither is proof, and intent is
393
+ * not recoverable from structure, so this decides nothing and only says what
394
+ * the two blocks contain.
395
+ */
396
+ export function diffScenarioNames(current, incoming) {
397
+ const currentNames = parseScenarioBlocks(current.raw).map((scenario) => scenario.name);
398
+ const incomingNames = parseScenarioBlocks(incoming.raw).map((scenario) => scenario.name);
399
+ // Multiplicity-aware: a name present N times on one side and M times on the
400
+ // other leaves max(0, N - M) instances unmatched. Set membership would treat
401
+ // N>M as fully covered and let archive silently drop duplicates (residual
402
+ // #1246 / duplicate-scenario-name blind spot).
403
+ const unmatched = (names, against) => {
404
+ const remaining = new Map();
405
+ for (const name of against)
406
+ remaining.set(name, (remaining.get(name) ?? 0) + 1);
407
+ const out = [];
408
+ for (const name of names) {
409
+ const left = remaining.get(name) ?? 0;
410
+ if (left > 0)
411
+ remaining.set(name, left - 1);
412
+ else
413
+ out.push(name);
414
+ }
415
+ return out;
416
+ };
417
+ return {
418
+ missing: unmatched(currentNames, incomingNames),
419
+ added: unmatched(incomingNames, currentNames),
420
+ currentCount: currentNames.length,
421
+ incomingCount: incomingNames.length,
422
+ };
423
+ }
383
424
  /**
384
425
  * Scenario names the current requirement block has and the incoming
385
426
  * (MODIFIED) block does not. A MODIFIED requirement replaces the whole block,
386
427
  * so every name reported here would be dropped from the main spec.
387
428
  *
388
- * Shared by archive (which refuses to apply the block) and validate (which
389
- * reports the same loss at authoring time, #1477), so the two cannot disagree
390
- * about what counts as a dropped scenario.
429
+ * The `missing` half of diffScenarioNames, which archive (refusing to apply
430
+ * the block) and validate (reporting the same loss at authoring time, #1477)
431
+ * both go through, so the two cannot disagree about what counts as a dropped
432
+ * scenario.
391
433
  */
392
434
  export function findMissingCurrentScenarios(current, incoming) {
393
- // Multiplicity-aware: a name present N times in current and M times in
394
- // incoming means max(0, N - M) instances are missing. Set membership would
395
- // treat N>M as fully covered and let archive silently drop duplicates
396
- // (residual #1246 / duplicate-scenario-name blind spot).
397
- const remainingIncoming = new Map();
398
- for (const scenario of parseScenarioBlocks(incoming.raw)) {
399
- const name = scenario.name;
400
- remainingIncoming.set(name, (remainingIncoming.get(name) ?? 0) + 1);
401
- }
402
- const missing = [];
403
- for (const scenario of parseScenarioBlocks(current.raw)) {
404
- const name = scenario.name;
405
- const remaining = remainingIncoming.get(name) ?? 0;
406
- if (remaining > 0) {
407
- remainingIncoming.set(name, remaining - 1);
408
- }
409
- else {
410
- missing.push(name);
411
- }
435
+ return diffScenarioNames(current, incoming).missing;
436
+ }
437
+ /** At most this many added names are listed before the rest are counted. */
438
+ const MAX_LISTED_ADDED_SCENARIOS = 3;
439
+ /**
440
+ * The one sentence archive and validate both append when the guard fires, so
441
+ * the counts a reader sees cannot differ between the two commands.
442
+ */
443
+ export function describeScenarioBalance(diff) {
444
+ const count = (value) => `${value} ${value === 1 ? 'scenario' : 'scenarios'}`;
445
+ const scale = `The modified block has ${count(diff.incomingCount)}; the current spec has ${count(diff.currentCount)}.`;
446
+ if (diff.added.length === 0) {
447
+ return `${scale} It adds none.`;
412
448
  }
413
- return missing;
449
+ const listed = diff.added
450
+ .slice(0, MAX_LISTED_ADDED_SCENARIOS)
451
+ .map((name) => `"${name}"`)
452
+ .join(', ');
453
+ const rest = diff.added.length - MAX_LISTED_ADDED_SCENARIOS;
454
+ const names = rest > 0 ? `${listed} and ${rest} more` : listed;
455
+ return `${scale} It adds ${count(diff.added.length)} not in the current spec: ${names}.`;
414
456
  }
415
457
  /**
416
458
  * Any non-fenced level-4 header on the given (masked) line. Reuses the spec
@@ -429,8 +471,15 @@ function scenarioHeaderAt(lines, mask, index) {
429
471
  * ATX-closed, one not) are not mistaken for a dropped scenario.
430
472
  */
431
473
  function scenarioNameAt(line) {
432
- return line
433
- .replace(SCENARIO_HEADER, '')
474
+ return scenarioNameFromHeaderText(line.replace(SCENARIO_HEADER, ''));
475
+ }
476
+ /**
477
+ * scenarioNameAt for header text whose leading `####` is already gone, as the
478
+ * section parser (MarkdownParser) holds it, so `show --json` names a scenario
479
+ * exactly as the MODIFIED loss check does.
480
+ */
481
+ export function scenarioNameFromHeaderText(headerText) {
482
+ return headerText
434
483
  // Optional ATX closing sequence. CommonMark only treats a trailing `#` run
435
484
  // as a close when it is preceded by a space or tab — not any Unicode space —
436
485
  // so this uses `[ \t]`, not `\s`. A looser `\s` could strip a `#` run after