@fission-ai/openspec 1.13.0 → 1.13.2

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 (113) hide show
  1. package/README.md +16 -4
  2. package/dist/commands/change.js +12 -1
  3. package/dist/commands/config.d.ts +9 -0
  4. package/dist/commands/config.js +161 -22
  5. package/dist/commands/feedback.js +6 -4
  6. package/dist/commands/schema.js +30 -9
  7. package/dist/commands/store.js +5 -2
  8. package/dist/commands/validate.d.ts +7 -0
  9. package/dist/commands/validate.js +63 -1
  10. package/dist/commands/workflow/instructions.js +69 -22
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.d.ts +5 -0
  13. package/dist/commands/workflow/shared.js +8 -0
  14. package/dist/commands/workflow/status.d.ts +5 -1
  15. package/dist/commands/workflow/status.js +42 -5
  16. package/dist/core/archive.js +260 -77
  17. package/dist/core/artifact-graph/outputs.d.ts +1 -1
  18. package/dist/core/artifact-graph/outputs.js +38 -8
  19. package/dist/core/artifact-graph/schema.d.ts +14 -0
  20. package/dist/core/artifact-graph/schema.js +52 -0
  21. package/dist/core/artifact-graph/types.js +12 -1
  22. package/dist/core/change-status-policy.d.ts +14 -0
  23. package/dist/core/change-status-policy.js +14 -6
  24. package/dist/core/command-generation/adapters/continue.js +4 -0
  25. package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
  26. package/dist/core/command-generation/adapters/kilocode.js +2 -2
  27. package/dist/core/command-generation/generator.js +2 -0
  28. package/dist/core/completion-tip.js +7 -7
  29. package/dist/core/completions/installers/bash-installer.js +15 -7
  30. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  31. package/dist/core/completions/installers/shell-quote.js +13 -0
  32. package/dist/core/completions/installers/zsh-installer.js +3 -2
  33. package/dist/core/config-schema.d.ts +1 -1
  34. package/dist/core/config.d.ts +16 -0
  35. package/dist/core/config.js +27 -1
  36. package/dist/core/global-config.d.ts +30 -2
  37. package/dist/core/global-config.js +57 -3
  38. package/dist/core/init.js +22 -6
  39. package/dist/core/legacy-cleanup.d.ts +5 -1
  40. package/dist/core/legacy-cleanup.js +207 -14
  41. package/dist/core/list.js +55 -11
  42. package/dist/core/migration.js +6 -1
  43. package/dist/core/parsers/change-parser.d.ts +12 -1
  44. package/dist/core/parsers/change-parser.js +85 -87
  45. package/dist/core/parsers/markdown-parser.js +4 -3
  46. package/dist/core/parsers/requirement-blocks.d.ts +78 -6
  47. package/dist/core/parsers/requirement-blocks.js +155 -38
  48. package/dist/core/parsers/requirement-text.d.ts +13 -2
  49. package/dist/core/parsers/requirement-text.js +34 -7
  50. package/dist/core/parsers/spec-structure.js +4 -1
  51. package/dist/core/project-config.d.ts +9 -1
  52. package/dist/core/project-config.js +11 -2
  53. package/dist/core/references.d.ts +20 -0
  54. package/dist/core/references.js +56 -0
  55. package/dist/core/shared/skill-content-equivalence.js +5 -1
  56. package/dist/core/shared/skill-generation.js +36 -8
  57. package/dist/core/shared/tool-detection.js +9 -3
  58. package/dist/core/specs-apply.d.ts +20 -0
  59. package/dist/core/specs-apply.js +101 -17
  60. package/dist/core/store/git.d.ts +33 -0
  61. package/dist/core/store/git.js +60 -6
  62. package/dist/core/store/operations.d.ts +1 -1
  63. package/dist/core/store/operations.js +32 -10
  64. package/dist/core/store/registry.d.ts +2 -1
  65. package/dist/core/store/registry.js +5 -1
  66. package/dist/core/templates/optional-workflow.d.ts +69 -0
  67. package/dist/core/templates/optional-workflow.js +140 -0
  68. package/dist/core/templates/workflows/apply-change.js +18 -3
  69. package/dist/core/templates/workflows/archive-change.js +70 -21
  70. package/dist/core/templates/workflows/bulk-archive-change.js +85 -21
  71. package/dist/core/templates/workflows/continue-change.js +14 -4
  72. package/dist/core/templates/workflows/explore.js +34 -16
  73. package/dist/core/templates/workflows/ff-change.js +21 -5
  74. package/dist/core/templates/workflows/new-change.js +15 -3
  75. package/dist/core/templates/workflows/onboard.js +76 -41
  76. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  77. package/dist/core/templates/workflows/project-root.js +38 -0
  78. package/dist/core/templates/workflows/propose.js +22 -5
  79. package/dist/core/templates/workflows/sync-specs.js +40 -1
  80. package/dist/core/templates/workflows/update-change.js +66 -27
  81. package/dist/core/templates/workflows/verify-change.js +121 -49
  82. package/dist/core/update.d.ts +12 -0
  83. package/dist/core/update.js +78 -10
  84. package/dist/core/validation/purpose-placeholder.js +24 -9
  85. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  86. package/dist/core/validation/task-checkboxes.js +181 -0
  87. package/dist/core/validation/validator.d.ts +53 -1
  88. package/dist/core/validation/validator.js +160 -45
  89. package/dist/core/version-check.d.ts +14 -7
  90. package/dist/core/version-check.js +63 -17
  91. package/dist/core/worksets.js +6 -4
  92. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  93. package/dist/prompts/searchable-multi-select.js +35 -6
  94. package/dist/telemetry/config.js +14 -2
  95. package/dist/telemetry/index.d.ts +4 -3
  96. package/dist/telemetry/index.js +22 -10
  97. package/dist/telemetry/opt-out.d.ts +17 -0
  98. package/dist/telemetry/opt-out.js +27 -0
  99. package/dist/utils/file-system.js +20 -3
  100. package/dist/utils/line-endings.d.ts +34 -0
  101. package/dist/utils/line-endings.js +46 -0
  102. package/dist/utils/nested-change.d.ts +35 -0
  103. package/dist/utils/nested-change.js +186 -0
  104. package/dist/utils/spec-discovery.d.ts +20 -0
  105. package/dist/utils/spec-discovery.js +59 -0
  106. package/dist/utils/task-progress.d.ts +1 -1
  107. package/dist/utils/task-progress.js +38 -6
  108. package/package.json +5 -5
  109. package/schemas/spec-driven/schema.yaml +18 -3
  110. package/schemas/spec-driven/templates/design.md +2 -0
  111. package/schemas/spec-driven/templates/proposal.md +2 -0
  112. package/schemas/spec-driven/templates/spec.md +2 -0
  113. package/schemas/spec-driven/templates/tasks.md +2 -0
@@ -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.
@@ -22,26 +23,45 @@ export const LEGACY_CONFIG_FILES = [
22
23
  'AGENTS.md', // root AGENTS.md (not openspec/AGENTS.md)
23
24
  'QWEN.md',
24
25
  ];
26
+ /** The three commands the old SlashCommandRegistry wrote into each directory. */
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
+ ];
25
35
  /**
26
36
  * Legacy slash command patterns from the old SlashCommandRegistry.
27
37
  * These map toolId to the path pattern where legacy commands were created.
28
38
  * Some tools used a directory structure, others used individual files.
29
39
  */
30
40
  export const LEGACY_SLASH_COMMAND_PATHS = {
31
- // Directory-based: .tooldir/commands/openspec/ or .tooldir/commands/openspec/*.md
32
- 'claude': { type: 'directory', path: '.claude/commands/openspec' },
33
- 'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec' },
34
- 'qoder': { type: 'directory', path: '.qoder/commands/openspec' },
35
- 'lingma': { type: 'directory', path: '.lingma/commands/openspec' },
36
- 'crush': { type: 'directory', path: '.crush/commands/openspec' },
37
- 'gemini': { type: 'directory', path: '.gemini/commands/openspec' },
41
+ // Directory-based: .tooldir/commands/openspec/. Each entry names the files
42
+ // OpenSpec wrote there, because users keep their own commands in the same
43
+ // folder: only those files are deleted, and the folder only once it is empty.
44
+ 'claude': { type: 'directory', path: '.claude/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
45
+ 'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
46
+ 'qoder': { type: 'directory', path: '.qoder/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
47
+ // Lingma support arrived after the opsx rename and has always written to
48
+ // `.lingma/commands/opsx/`, so OpenSpec never put a file here: only an empty
49
+ // leftover folder is removed.
50
+ 'lingma': { type: 'directory', path: '.lingma/commands/openspec', managedFileNames: [] },
51
+ 'crush': { type: 'directory', path: '.crush/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
52
+ 'gemini': { type: 'directory', path: '.gemini/commands/openspec', managedFileNames: ['proposal.toml', 'apply.toml', 'archive.toml'] },
38
53
  // File-based: individual openspec-*.md files in a commands/workflows/prompts folder
39
54
  'cursor': { type: 'files', pattern: '.cursor/commands/openspec-*.md' },
40
55
  // Keyed by the tool id these map back to, so the pre-opsx Windsurf files
41
56
  // belong to `devin` — the id Windsurf became. Only `.windsurf/` is listed:
42
57
  // `.devin/` postdates the opsx rename and never held `openspec-*` files.
43
58
  'devin': { type: 'files', pattern: '.windsurf/workflows/openspec-*.md' },
44
- '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
+ },
45
65
  'kiro': { type: 'files', pattern: '.kiro/prompts/openspec-*.prompt.md' },
46
66
  'github-copilot': { type: 'files', pattern: '.github/prompts/openspec-*.prompt.md' },
47
67
  'amazon-q': { type: 'files', pattern: '.amazonq/prompts/openspec-*.md' },
@@ -221,9 +241,22 @@ export async function detectLegacySlashCommands(projectPath) {
221
241
  for (const pattern of Object.values(LEGACY_SLASH_COMMAND_PATHS)) {
222
242
  if (pattern.type === 'directory' && pattern.path) {
223
243
  const dirPath = FileSystemUtils.joinPath(projectPath, pattern.path);
224
- if (await FileSystemUtils.directoryExists(dirPath)) {
244
+ if (!(await FileSystemUtils.directoryExists(dirPath))) {
245
+ continue;
246
+ }
247
+ const entries = await readLegacyCommandDir(dirPath, pattern.managedFileNames ?? []);
248
+ if (!entries) {
249
+ continue;
250
+ }
251
+ if (entries.others.length === 0) {
252
+ // Only OpenSpec's own files, or nothing: the whole folder can go.
225
253
  directories.push(pattern.path);
226
254
  }
255
+ else {
256
+ // The folder also holds the user's files, so report OpenSpec's files
257
+ // one by one; cleanup deletes those and leaves the folder in place.
258
+ files.push(...entries.managed.map((name) => `${pattern.path}/${name}`));
259
+ }
227
260
  }
228
261
  else if (pattern.type === 'files' && pattern.pattern) {
229
262
  const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
@@ -235,6 +268,104 @@ export async function detectLegacySlashCommands(projectPath) {
235
268
  }
236
269
  return { directories, files };
237
270
  }
271
+ /**
272
+ * Splits a legacy command directory's entries into the files OpenSpec wrote
273
+ * there and everything else, sorted. A file counts as OpenSpec's only when it
274
+ * is a regular file with a managed name whose content still carries the
275
+ * OpenSpec markers every legacy command was written with; a folder, a link, or
276
+ * a same-named file the user wrote is the user's. Subdirectories are listed
277
+ * with a trailing '/'. Returns undefined when the directory cannot be read or
278
+ * is itself a symlink, which is never followed.
279
+ */
280
+ async function readLegacyCommandDir(dirPath, managedFileNames) {
281
+ let entries;
282
+ try {
283
+ if ((await fs.lstat(dirPath)).isSymbolicLink()) {
284
+ return undefined;
285
+ }
286
+ entries = await fs.readdir(dirPath, { withFileTypes: true });
287
+ }
288
+ catch {
289
+ return undefined;
290
+ }
291
+ const managed = [];
292
+ const others = [];
293
+ for (const entry of entries) {
294
+ if (entry.isFile() &&
295
+ managedFileNames.includes(entry.name) &&
296
+ (await isGeneratedLegacyCommand(path.join(dirPath, entry.name)))) {
297
+ managed.push(entry.name);
298
+ }
299
+ else {
300
+ others.push(entry.isDirectory() ? `${entry.name}/` : entry.name);
301
+ }
302
+ }
303
+ return { managed: managed.sort(), others: others.sort() };
304
+ }
305
+ /**
306
+ * The legacy command directory, and its tool, that a repo-local path is one of
307
+ * OpenSpec's own files in.
308
+ */
309
+ function legacyCommandDirForFile(file) {
310
+ const normalizedFile = normalizePathForMatch(file);
311
+ for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
312
+ if (pattern.type !== 'directory' || !pattern.path)
313
+ continue;
314
+ const dir = pattern.path;
315
+ if (pattern.managedFileNames?.some((name) => normalizedFile === `${dir}/${name}`)) {
316
+ return { toolId, dir };
317
+ }
318
+ }
319
+ return undefined;
320
+ }
321
+ /**
322
+ * Removes a legacy command directory once OpenSpec's files are gone from it,
323
+ * or records what is left in it as kept. Never recursive: whatever remains was
324
+ * not written by OpenSpec. Returns true when the directory was removed.
325
+ */
326
+ async function settleLegacyCommandDir(projectPath, dirPath, result) {
327
+ const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
328
+ const remaining = await readLegacyCommandDir(fullPath, []);
329
+ if (!remaining) {
330
+ return false;
331
+ }
332
+ if (remaining.others.length === 0) {
333
+ await fs.rmdir(fullPath);
334
+ result.deletedDirs.push(dirPath);
335
+ return true;
336
+ }
337
+ result.keptFiles.push(...remaining.others.map((name) => `${dirPath}/${name}`));
338
+ return false;
339
+ }
340
+ /**
341
+ * Whether a file is a legacy command OpenSpec generated: a regular file (not a
342
+ * link) whose content carries the OpenSpec markers. Every legacy slash command
343
+ * was written with them, and OpenSpec refused to update one that lost them, so
344
+ * a same-named file without them is the user's.
345
+ */
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;
352
+ try {
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()) {
358
+ return false;
359
+ }
360
+ return hasOpenSpecMarkers(await handle.readFile('utf-8'));
361
+ }
362
+ catch {
363
+ return false;
364
+ }
365
+ finally {
366
+ await handle?.close();
367
+ }
368
+ }
238
369
  /**
239
370
  * Detects legacy global slash command files.
240
371
  *
@@ -376,6 +507,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
376
507
  deletedFileReplacementLabels: {},
377
508
  modifiedFiles: [],
378
509
  deletedDirs: [],
510
+ keptFiles: [],
379
511
  projectMdNeedsMigration: detection.hasProjectMd,
380
512
  errors: [],
381
513
  };
@@ -394,21 +526,50 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
394
526
  result.errors.push(`Failed to modify ${fileName}: ${error.message}`);
395
527
  }
396
528
  }
397
- // Delete legacy slash command directories (these are 100% OpenSpec-managed)
529
+ // Delete legacy slash command directories: only the files OpenSpec wrote,
530
+ // then the directory once it is empty. Detection reports a directory only
531
+ // when it holds nothing else, but a file the user added since is still kept.
398
532
  for (const dirPath of detection.slashCommandDirs) {
399
533
  const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
400
534
  try {
401
- await fs.rm(fullPath, { recursive: true, force: true });
402
- result.deletedDirs.push(dirPath);
535
+ const managedFileNames = legacyManagedFileNamesForDir(dirPath);
536
+ const entries = await readLegacyCommandDir(fullPath, managedFileNames);
537
+ if (!entries) {
538
+ continue;
539
+ }
540
+ const deleted = [];
541
+ for (const name of entries.managed) {
542
+ const filePath = path.join(fullPath, name);
543
+ // Check again just before deleting: the file may have been replaced
544
+ // with the user's own since the scan. A kept file is reported below.
545
+ if (!(await isGeneratedLegacyCommand(filePath))) {
546
+ continue;
547
+ }
548
+ await fs.unlink(filePath);
549
+ deleted.push(name);
550
+ }
551
+ if (!(await settleLegacyCommandDir(projectPath, dirPath, result))) {
552
+ result.deletedFiles.push(...deleted.map((name) => `${dirPath}/${name}`));
553
+ }
403
554
  }
404
555
  catch (error) {
405
556
  result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
406
557
  }
407
558
  }
408
559
  // Delete legacy slash command files (these are 100% OpenSpec-managed)
560
+ const partlyCleanedDirs = new Set();
409
561
  for (const filePath of detection.slashCommandFiles) {
410
562
  const fullPath = FileSystemUtils.joinPath(projectPath, filePath);
411
563
  try {
564
+ const commandDir = legacyCommandDirForFile(filePath);
565
+ if (commandDir) {
566
+ partlyCleanedDirs.add(commandDir.dir);
567
+ // Check again just before deleting: the file may have been replaced
568
+ // with the user's own since detection. A kept file is reported below.
569
+ if (!(await isGeneratedLegacyCommand(fullPath))) {
570
+ continue;
571
+ }
572
+ }
412
573
  await fs.unlink(fullPath);
413
574
  result.deletedFiles.push(filePath);
414
575
  }
@@ -416,6 +577,16 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
416
577
  result.errors.push(`Failed to delete ${filePath}: ${error.message}`);
417
578
  }
418
579
  }
580
+ // A legacy command directory that also held the user's files was cleaned
581
+ // file by file above; record what was left in it.
582
+ for (const dirPath of partlyCleanedDirs) {
583
+ try {
584
+ await settleLegacyCommandDir(projectPath, dirPath, result);
585
+ }
586
+ catch (error) {
587
+ result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
588
+ }
589
+ }
419
590
  // Delete managed global slash command files (these are 100% OpenSpec-managed)
420
591
  const globalPromptMatchesByPath = new Map(getLegacyGlobalPromptMatches(detection).map((prompt) => [prompt.path, prompt]));
421
592
  for (const filePath of detection.globalSlashCommandFiles) {
@@ -461,7 +632,11 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
461
632
  */
462
633
  export function formatCleanupSummary(result) {
463
634
  const lines = [];
464
- if (result.deletedFiles.length > 0 || result.deletedDirs.length > 0 || result.modifiedFiles.length > 0) {
635
+ const keptFiles = result.keptFiles ?? [];
636
+ if (result.deletedFiles.length > 0 ||
637
+ result.deletedDirs.length > 0 ||
638
+ result.modifiedFiles.length > 0 ||
639
+ keptFiles.length > 0) {
465
640
  lines.push('Cleaned up legacy files:');
466
641
  for (const file of result.deletedFiles) {
467
642
  const replacementLabel = result.deletedFileReplacementLabels?.[file]
@@ -474,6 +649,9 @@ export function formatCleanupSummary(result) {
474
649
  for (const dir of result.deletedDirs) {
475
650
  lines.push(` ✓ Removed ${dir}/ (replaced by OpenSpec skills and commands)`);
476
651
  }
652
+ for (const entry of keptFiles) {
653
+ lines.push(` • Kept ${entry} (not created by OpenSpec)`);
654
+ }
477
655
  for (const file of result.modifiedFiles) {
478
656
  lines.push(` ✓ Removed OpenSpec markers from ${file}`);
479
657
  }
@@ -643,8 +821,23 @@ function legacyToolIdForDir(dir) {
643
821
  }
644
822
  return undefined;
645
823
  }
824
+ /** The files OpenSpec wrote into a repo-local legacy slash-command directory. */
825
+ function legacyManagedFileNamesForDir(dir) {
826
+ const normalizedDir = normalizePathForMatch(dir);
827
+ for (const pattern of Object.values(LEGACY_SLASH_COMMAND_PATHS)) {
828
+ if (pattern.type === 'directory' && pattern.path === normalizedDir) {
829
+ return pattern.managedFileNames ?? [];
830
+ }
831
+ }
832
+ return [];
833
+ }
646
834
  /** The tool that owns a repo-local legacy slash-command file, if any. */
647
835
  function legacyToolIdForFile(file) {
836
+ // A file from a directory-based tool, reported because the directory also
837
+ // holds the user's own files.
838
+ const commandDir = legacyCommandDirForFile(file);
839
+ if (commandDir)
840
+ return commandDir.toolId;
648
841
  // Normalize to forward slashes so the glob patterns match on Windows too.
649
842
  const normalizedFile = normalizePathForMatch(file);
650
843
  for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
package/dist/core/list.js CHANGED
@@ -4,12 +4,24 @@ import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progre
4
4
  import { readFileSync } from 'fs';
5
5
  import { MarkdownParser } from './parsers/markdown-parser.js';
6
6
  import { discoverSpecFiles } from '../utils/spec-discovery.js';
7
+ import { describeNestedChange, findNestedChanges, } from '../utils/nested-change.js';
7
8
  function isMissingPathError(error) {
8
9
  return (typeof error === 'object' &&
9
10
  error !== null &&
10
11
  'code' in error &&
11
12
  error.code === 'ENOENT');
12
13
  }
14
+ /**
15
+ * An entry that cannot be dated because it no longer resolves: it was removed
16
+ * after `readdir` listed it, or it is a symlink whose target is missing (an
17
+ * Emacs `.#file` lock) or that loops back on itself.
18
+ */
19
+ function isUnresolvableEntryError(error) {
20
+ if (typeof error !== 'object' || error === null || !('code' in error))
21
+ return false;
22
+ const code = error.code;
23
+ return code === 'ENOENT' || code === 'ELOOP';
24
+ }
13
25
  async function readChangeDirectoryEntries(changesDir) {
14
26
  try {
15
27
  return await fs.readdir(changesDir, { withFileTypes: true });
@@ -30,14 +42,21 @@ async function getLastModified(dirPath) {
30
42
  const entries = await fs.readdir(dir, { withFileTypes: true });
31
43
  for (const entry of entries) {
32
44
  const fullPath = path.join(dir, entry.name);
33
- if (entry.isDirectory()) {
34
- await walk(fullPath);
35
- }
36
- else {
37
- const stat = await fs.stat(fullPath);
38
- if (latest === null || stat.mtime > latest) {
39
- latest = stat.mtime;
45
+ try {
46
+ if (entry.isDirectory()) {
47
+ await walk(fullPath);
40
48
  }
49
+ else {
50
+ const stat = await fs.stat(fullPath);
51
+ if (latest === null || stat.mtime > latest) {
52
+ latest = stat.mtime;
53
+ }
54
+ }
55
+ }
56
+ catch (error) {
57
+ // Skip the one entry rather than fail the listing of every change.
58
+ if (!isUnresolvableEntryError(error))
59
+ throw error;
41
60
  }
42
61
  }
43
62
  }
@@ -96,6 +115,11 @@ export class ListCommand {
96
115
  }
97
116
  // Collect information about each change
98
117
  const changes = [];
118
+ // A directory that only wraps nested change directories is still listed -
119
+ // hiding it would hide a real change whenever the probe is wrong - but it
120
+ // is listed as what it is, so the nesting stops failing silently (#1846).
121
+ const nestedFindings = await findNestedChanges(changesDir, changeDirs);
122
+ const nestedByName = new Map(nestedFindings.map((finding) => [finding.name, finding]));
99
123
  for (const changeDir of changeDirs) {
100
124
  const progress = await getTaskProgressForChange(changesDir, changeDir, targetPath);
101
125
  const changePath = path.join(changesDir, changeDir);
@@ -104,7 +128,8 @@ export class ListCommand {
104
128
  name: changeDir,
105
129
  completedTasks: progress.completed,
106
130
  totalTasks: progress.total,
107
- lastModified
131
+ lastModified,
132
+ ...(nestedByName.has(changeDir) ? { nested: nestedByName.get(changeDir).nested } : {})
108
133
  });
109
134
  }
110
135
  // Sort by preference (default: recent first)
@@ -121,9 +146,22 @@ export class ListCommand {
121
146
  completedTasks: c.completedTasks,
122
147
  totalTasks: c.totalTasks,
123
148
  lastModified: c.lastModified.toISOString(),
124
- status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress'
149
+ status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress',
150
+ ...(c.nested ? { nested: c.nested } : {})
125
151
  }));
126
- console.log(JSON.stringify({ changes: jsonOutput, ...(root ? { root } : {}) }, null, 2));
152
+ // Additive: the entries keep their shape so existing consumers are
153
+ // unaffected, and the nesting is reported alongside them.
154
+ const warnings = nestedFindings.map((finding) => ({
155
+ code: 'nested_change_directory',
156
+ name: finding.name,
157
+ nested: finding.nested,
158
+ message: describeNestedChange(finding)
159
+ }));
160
+ console.log(JSON.stringify({
161
+ changes: jsonOutput,
162
+ ...(warnings.length > 0 ? { warnings } : {}),
163
+ ...(root ? { root } : {})
164
+ }, null, 2));
127
165
  return;
128
166
  }
129
167
  // Display results
@@ -132,10 +170,16 @@ export class ListCommand {
132
170
  const nameWidth = Math.max(...changes.map(c => c.name.length));
133
171
  for (const change of changes) {
134
172
  const paddedName = change.name.padEnd(nameWidth);
135
- const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
173
+ const status = change.nested
174
+ ? 'not a change'
175
+ : formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
136
176
  const timeAgo = formatRelativeTime(change.lastModified);
137
177
  console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`);
138
178
  }
179
+ for (const finding of nestedFindings) {
180
+ console.log('');
181
+ console.log(`Warning: ${describeNestedChange(finding)}`);
182
+ }
139
183
  return;
140
184
  }
141
185
  // specs mode
@@ -5,7 +5,7 @@
5
5
  * Called by both init and update commands before profile resolution.
6
6
  */
7
7
  import { AI_TOOLS } from './config.js';
8
- import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig } from './global-config.js';
8
+ import { getGlobalConfig, getGlobalConfigPath, isGlobalConfigUnreadable, saveGlobalConfig } from './global-config.js';
9
9
  import { CommandAdapterRegistry } from './command-generation/index.js';
10
10
  import { resolveCommandInvocation, resolveCommandSurfaceCapability, shouldGenerateCommandsForTool, } from './command-surface.js';
11
11
  import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js';
@@ -435,6 +435,11 @@ function inferDelivery(artifacts) {
435
435
  * - If profile field already exists: no-op.
436
436
  */
437
437
  export function migrateIfNeeded(projectPath, tools) {
438
+ // A config that cannot be parsed, or is not a JSON object, is never saved
439
+ // over; skip migration rather than fail init or update on it.
440
+ if (isGlobalConfigUnreadable()) {
441
+ return;
442
+ }
438
443
  const config = getGlobalConfig();
439
444
  // Check raw config file for profile field presence
440
445
  const configPath = getGlobalConfigPath();
@@ -20,8 +20,19 @@ export declare class ChangeParser extends MarkdownParser {
20
20
  * `view`, `list`, `spec --json`, spec validation — untouched.
21
21
  */
22
22
  protected parseRequirements(section: Section): Requirement[];
23
+ /**
24
+ * The deltas in one spec file, read by parseDeltaSpec — the reader archive
25
+ * applies — so what `show` reports is what archive will do. This used to be a
26
+ * second reader that disagreed with it: a bullet-form REMOVED was invisible,
27
+ * a repeated section header was read only once, and a RENAMED line written
28
+ * with `*` or `+` was dropped.
29
+ */
23
30
  private parseSpecDeltas;
24
- private parseRenames;
31
+ /**
32
+ * One Requirement per block, read by the same section parser (and the
33
+ * header filter above) as before, so text and scenarios are unchanged.
34
+ */
35
+ private toRequirements;
25
36
  private parseSectionsFromContent;
26
37
  private getContentUntilNextHeaderFromLines;
27
38
  }