@fission-ai/openspec 1.12.0 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -15
- package/dist/commands/change.js +12 -1
- package/dist/commands/config.d.ts +9 -0
- package/dist/commands/config.js +161 -22
- package/dist/commands/feedback.js +6 -4
- package/dist/commands/schema.js +30 -9
- package/dist/commands/store.js +5 -2
- package/dist/commands/validate.d.ts +7 -0
- package/dist/commands/validate.js +63 -1
- package/dist/commands/workflow/instructions.js +170 -16
- package/dist/commands/workflow/new-change.js +20 -0
- package/dist/commands/workflow/shared.d.ts +8 -0
- package/dist/commands/workflow/shared.js +8 -0
- package/dist/commands/workflow/status.d.ts +5 -1
- package/dist/commands/workflow/status.js +42 -5
- package/dist/core/archive.js +17 -1
- package/dist/core/artifact-graph/schema.d.ts +14 -0
- package/dist/core/artifact-graph/schema.js +52 -0
- package/dist/core/artifact-graph/types.js +12 -1
- package/dist/core/change-status-policy.d.ts +14 -0
- package/dist/core/change-status-policy.js +14 -6
- package/dist/core/command-generation/generator.js +2 -0
- package/dist/core/completion-tip.js +7 -7
- package/dist/core/completions/installers/bash-installer.js +15 -7
- package/dist/core/completions/installers/shell-quote.d.ts +11 -0
- package/dist/core/completions/installers/shell-quote.js +13 -0
- package/dist/core/completions/installers/zsh-installer.js +3 -2
- package/dist/core/config-schema.d.ts +1 -1
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +27 -1
- package/dist/core/global-config.d.ts +30 -2
- package/dist/core/global-config.js +57 -3
- package/dist/core/init.js +33 -3
- package/dist/core/legacy-cleanup.d.ts +4 -0
- package/dist/core/legacy-cleanup.js +180 -12
- package/dist/core/list.js +55 -11
- package/dist/core/migration.js +6 -1
- package/dist/core/onboarding-commands.d.ts +11 -1
- package/dist/core/onboarding-commands.js +26 -0
- package/dist/core/parsers/change-parser.d.ts +12 -1
- package/dist/core/parsers/change-parser.js +85 -87
- package/dist/core/parsers/markdown-parser.js +4 -3
- package/dist/core/parsers/requirement-blocks.d.ts +44 -3
- package/dist/core/parsers/requirement-blocks.js +164 -43
- package/dist/core/parsers/requirement-text.d.ts +13 -2
- package/dist/core/parsers/requirement-text.js +34 -7
- package/dist/core/parsers/spec-structure.js +4 -1
- package/dist/core/project-config.d.ts +9 -1
- package/dist/core/project-config.js +11 -2
- package/dist/core/references.d.ts +20 -0
- package/dist/core/references.js +56 -0
- package/dist/core/shared/skill-content-equivalence.js +5 -1
- package/dist/core/shared/skill-generation.js +36 -8
- package/dist/core/shared/tool-detection.js +37 -4
- package/dist/core/specs-apply.js +260 -18
- package/dist/core/store/git.d.ts +33 -0
- package/dist/core/store/git.js +60 -6
- package/dist/core/store/operations.d.ts +1 -1
- package/dist/core/store/operations.js +32 -10
- package/dist/core/store/registry.d.ts +2 -1
- package/dist/core/store/registry.js +5 -1
- package/dist/core/templates/optional-workflow.d.ts +69 -0
- package/dist/core/templates/optional-workflow.js +140 -0
- package/dist/core/templates/workflows/apply-change.js +18 -3
- package/dist/core/templates/workflows/archive-change.js +50 -15
- package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
- package/dist/core/templates/workflows/continue-change.js +14 -2
- package/dist/core/templates/workflows/explore.js +48 -14
- package/dist/core/templates/workflows/ff-change.js +19 -3
- package/dist/core/templates/workflows/new-change.js +15 -3
- package/dist/core/templates/workflows/onboard.js +67 -37
- package/dist/core/templates/workflows/project-root.d.ts +29 -0
- package/dist/core/templates/workflows/project-root.js +38 -0
- package/dist/core/templates/workflows/propose.js +52 -15
- package/dist/core/templates/workflows/sync-specs.js +40 -1
- package/dist/core/templates/workflows/update-change.js +52 -25
- package/dist/core/templates/workflows/verify-change.js +12 -3
- package/dist/core/update.d.ts +34 -0
- package/dist/core/update.js +109 -11
- package/dist/core/validation/task-checkboxes.d.ts +34 -0
- package/dist/core/validation/task-checkboxes.js +181 -0
- package/dist/core/validation/validator.d.ts +53 -1
- package/dist/core/validation/validator.js +154 -40
- package/dist/core/version-check.d.ts +14 -7
- package/dist/core/version-check.js +63 -17
- package/dist/core/worksets.js +6 -4
- package/dist/prompts/searchable-multi-select.d.ts +7 -0
- package/dist/prompts/searchable-multi-select.js +35 -6
- package/dist/telemetry/config.js +14 -2
- package/dist/telemetry/index.d.ts +4 -3
- package/dist/telemetry/index.js +22 -10
- package/dist/telemetry/opt-out.d.ts +17 -0
- package/dist/telemetry/opt-out.js +27 -0
- package/dist/utils/nested-change.d.ts +35 -0
- package/dist/utils/nested-change.js +186 -0
- package/dist/utils/spec-discovery.d.ts +20 -0
- package/dist/utils/spec-discovery.js +59 -0
- package/dist/utils/task-progress.d.ts +1 -1
- package/dist/utils/task-progress.js +38 -6
- package/package.json +4 -4
- package/schemas/spec-driven/schema.yaml +24 -5
- package/schemas/spec-driven/templates/design.md +2 -0
- package/schemas/spec-driven/templates/proposal.md +2 -0
- package/schemas/spec-driven/templates/spec.md +2 -0
- package/schemas/spec-driven/templates/tasks.md +2 -0
|
@@ -22,19 +22,26 @@ export const LEGACY_CONFIG_FILES = [
|
|
|
22
22
|
'AGENTS.md', // root AGENTS.md (not openspec/AGENTS.md)
|
|
23
23
|
'QWEN.md',
|
|
24
24
|
];
|
|
25
|
+
/** The three commands the old SlashCommandRegistry wrote into each directory. */
|
|
26
|
+
const LEGACY_DIRECTORY_COMMAND_FILES = ['proposal.md', 'apply.md', 'archive.md'];
|
|
25
27
|
/**
|
|
26
28
|
* Legacy slash command patterns from the old SlashCommandRegistry.
|
|
27
29
|
* These map toolId to the path pattern where legacy commands were created.
|
|
28
30
|
* Some tools used a directory structure, others used individual files.
|
|
29
31
|
*/
|
|
30
32
|
export const LEGACY_SLASH_COMMAND_PATHS = {
|
|
31
|
-
// Directory-based: .tooldir/commands/openspec
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
|
|
33
|
+
// Directory-based: .tooldir/commands/openspec/. Each entry names the files
|
|
34
|
+
// OpenSpec wrote there, because users keep their own commands in the same
|
|
35
|
+
// folder: only those files are deleted, and the folder only once it is empty.
|
|
36
|
+
'claude': { type: 'directory', path: '.claude/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
|
|
37
|
+
'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
|
|
38
|
+
'qoder': { type: 'directory', path: '.qoder/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
|
|
39
|
+
// Lingma support arrived after the opsx rename and has always written to
|
|
40
|
+
// `.lingma/commands/opsx/`, so OpenSpec never put a file here: only an empty
|
|
41
|
+
// leftover folder is removed.
|
|
42
|
+
'lingma': { type: 'directory', path: '.lingma/commands/openspec', managedFileNames: [] },
|
|
43
|
+
'crush': { type: 'directory', path: '.crush/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
|
|
44
|
+
'gemini': { type: 'directory', path: '.gemini/commands/openspec', managedFileNames: ['proposal.toml', 'apply.toml', 'archive.toml'] },
|
|
38
45
|
// File-based: individual openspec-*.md files in a commands/workflows/prompts folder
|
|
39
46
|
'cursor': { type: 'files', pattern: '.cursor/commands/openspec-*.md' },
|
|
40
47
|
// Keyed by the tool id these map back to, so the pre-opsx Windsurf files
|
|
@@ -221,9 +228,22 @@ export async function detectLegacySlashCommands(projectPath) {
|
|
|
221
228
|
for (const pattern of Object.values(LEGACY_SLASH_COMMAND_PATHS)) {
|
|
222
229
|
if (pattern.type === 'directory' && pattern.path) {
|
|
223
230
|
const dirPath = FileSystemUtils.joinPath(projectPath, pattern.path);
|
|
224
|
-
if (await FileSystemUtils.directoryExists(dirPath)) {
|
|
231
|
+
if (!(await FileSystemUtils.directoryExists(dirPath))) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const entries = await readLegacyCommandDir(dirPath, pattern.managedFileNames ?? []);
|
|
235
|
+
if (!entries) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (entries.others.length === 0) {
|
|
239
|
+
// Only OpenSpec's own files, or nothing: the whole folder can go.
|
|
225
240
|
directories.push(pattern.path);
|
|
226
241
|
}
|
|
242
|
+
else {
|
|
243
|
+
// The folder also holds the user's files, so report OpenSpec's files
|
|
244
|
+
// one by one; cleanup deletes those and leaves the folder in place.
|
|
245
|
+
files.push(...entries.managed.map((name) => `${pattern.path}/${name}`));
|
|
246
|
+
}
|
|
227
247
|
}
|
|
228
248
|
else if (pattern.type === 'files' && pattern.pattern) {
|
|
229
249
|
const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
|
|
@@ -235,6 +255,92 @@ export async function detectLegacySlashCommands(projectPath) {
|
|
|
235
255
|
}
|
|
236
256
|
return { directories, files };
|
|
237
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Splits a legacy command directory's entries into the files OpenSpec wrote
|
|
260
|
+
* there and everything else, sorted. A file counts as OpenSpec's only when it
|
|
261
|
+
* is a regular file with a managed name whose content still carries the
|
|
262
|
+
* OpenSpec markers every legacy command was written with; a folder, a link, or
|
|
263
|
+
* a same-named file the user wrote is the user's. Subdirectories are listed
|
|
264
|
+
* with a trailing '/'. Returns undefined when the directory cannot be read or
|
|
265
|
+
* is itself a symlink, which is never followed.
|
|
266
|
+
*/
|
|
267
|
+
async function readLegacyCommandDir(dirPath, managedFileNames) {
|
|
268
|
+
let entries;
|
|
269
|
+
try {
|
|
270
|
+
if ((await fs.lstat(dirPath)).isSymbolicLink()) {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
const managed = [];
|
|
279
|
+
const others = [];
|
|
280
|
+
for (const entry of entries) {
|
|
281
|
+
if (entry.isFile() &&
|
|
282
|
+
managedFileNames.includes(entry.name) &&
|
|
283
|
+
(await isGeneratedLegacyCommand(path.join(dirPath, entry.name)))) {
|
|
284
|
+
managed.push(entry.name);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
others.push(entry.isDirectory() ? `${entry.name}/` : entry.name);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { managed: managed.sort(), others: others.sort() };
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The legacy command directory, and its tool, that a repo-local path is one of
|
|
294
|
+
* OpenSpec's own files in.
|
|
295
|
+
*/
|
|
296
|
+
function legacyCommandDirForFile(file) {
|
|
297
|
+
const normalizedFile = normalizePathForMatch(file);
|
|
298
|
+
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
|
|
299
|
+
if (pattern.type !== 'directory' || !pattern.path)
|
|
300
|
+
continue;
|
|
301
|
+
const dir = pattern.path;
|
|
302
|
+
if (pattern.managedFileNames?.some((name) => normalizedFile === `${dir}/${name}`)) {
|
|
303
|
+
return { toolId, dir };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Removes a legacy command directory once OpenSpec's files are gone from it,
|
|
310
|
+
* or records what is left in it as kept. Never recursive: whatever remains was
|
|
311
|
+
* not written by OpenSpec. Returns true when the directory was removed.
|
|
312
|
+
*/
|
|
313
|
+
async function settleLegacyCommandDir(projectPath, dirPath, result) {
|
|
314
|
+
const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
|
|
315
|
+
const remaining = await readLegacyCommandDir(fullPath, []);
|
|
316
|
+
if (!remaining) {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
if (remaining.others.length === 0) {
|
|
320
|
+
await fs.rmdir(fullPath);
|
|
321
|
+
result.deletedDirs.push(dirPath);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
result.keptFiles.push(...remaining.others.map((name) => `${dirPath}/${name}`));
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Whether a file is a legacy command OpenSpec generated: a regular file (not a
|
|
329
|
+
* link) whose content carries the OpenSpec markers. Every legacy slash command
|
|
330
|
+
* was written with them, and OpenSpec refused to update one that lost them, so
|
|
331
|
+
* a same-named file without them is the user's.
|
|
332
|
+
*/
|
|
333
|
+
async function isGeneratedLegacyCommand(filePath) {
|
|
334
|
+
try {
|
|
335
|
+
if (!(await fs.lstat(filePath)).isFile()) {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
return hasOpenSpecMarkers(await fs.readFile(filePath, 'utf-8'));
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
238
344
|
/**
|
|
239
345
|
* Detects legacy global slash command files.
|
|
240
346
|
*
|
|
@@ -376,6 +482,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
376
482
|
deletedFileReplacementLabels: {},
|
|
377
483
|
modifiedFiles: [],
|
|
378
484
|
deletedDirs: [],
|
|
485
|
+
keptFiles: [],
|
|
379
486
|
projectMdNeedsMigration: detection.hasProjectMd,
|
|
380
487
|
errors: [],
|
|
381
488
|
};
|
|
@@ -394,21 +501,50 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
394
501
|
result.errors.push(`Failed to modify ${fileName}: ${error.message}`);
|
|
395
502
|
}
|
|
396
503
|
}
|
|
397
|
-
// Delete legacy slash command directories
|
|
504
|
+
// Delete legacy slash command directories: only the files OpenSpec wrote,
|
|
505
|
+
// then the directory once it is empty. Detection reports a directory only
|
|
506
|
+
// when it holds nothing else, but a file the user added since is still kept.
|
|
398
507
|
for (const dirPath of detection.slashCommandDirs) {
|
|
399
508
|
const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
|
|
400
509
|
try {
|
|
401
|
-
|
|
402
|
-
|
|
510
|
+
const managedFileNames = legacyManagedFileNamesForDir(dirPath);
|
|
511
|
+
const entries = await readLegacyCommandDir(fullPath, managedFileNames);
|
|
512
|
+
if (!entries) {
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const deleted = [];
|
|
516
|
+
for (const name of entries.managed) {
|
|
517
|
+
const filePath = path.join(fullPath, name);
|
|
518
|
+
// Check again just before deleting: the file may have been replaced
|
|
519
|
+
// with the user's own since the scan. A kept file is reported below.
|
|
520
|
+
if (!(await isGeneratedLegacyCommand(filePath))) {
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
await fs.unlink(filePath);
|
|
524
|
+
deleted.push(name);
|
|
525
|
+
}
|
|
526
|
+
if (!(await settleLegacyCommandDir(projectPath, dirPath, result))) {
|
|
527
|
+
result.deletedFiles.push(...deleted.map((name) => `${dirPath}/${name}`));
|
|
528
|
+
}
|
|
403
529
|
}
|
|
404
530
|
catch (error) {
|
|
405
531
|
result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
|
|
406
532
|
}
|
|
407
533
|
}
|
|
408
534
|
// Delete legacy slash command files (these are 100% OpenSpec-managed)
|
|
535
|
+
const partlyCleanedDirs = new Set();
|
|
409
536
|
for (const filePath of detection.slashCommandFiles) {
|
|
410
537
|
const fullPath = FileSystemUtils.joinPath(projectPath, filePath);
|
|
411
538
|
try {
|
|
539
|
+
const commandDir = legacyCommandDirForFile(filePath);
|
|
540
|
+
if (commandDir) {
|
|
541
|
+
partlyCleanedDirs.add(commandDir.dir);
|
|
542
|
+
// Check again just before deleting: the file may have been replaced
|
|
543
|
+
// with the user's own since detection. A kept file is reported below.
|
|
544
|
+
if (!(await isGeneratedLegacyCommand(fullPath))) {
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
412
548
|
await fs.unlink(fullPath);
|
|
413
549
|
result.deletedFiles.push(filePath);
|
|
414
550
|
}
|
|
@@ -416,6 +552,16 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
416
552
|
result.errors.push(`Failed to delete ${filePath}: ${error.message}`);
|
|
417
553
|
}
|
|
418
554
|
}
|
|
555
|
+
// A legacy command directory that also held the user's files was cleaned
|
|
556
|
+
// file by file above; record what was left in it.
|
|
557
|
+
for (const dirPath of partlyCleanedDirs) {
|
|
558
|
+
try {
|
|
559
|
+
await settleLegacyCommandDir(projectPath, dirPath, result);
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
419
565
|
// Delete managed global slash command files (these are 100% OpenSpec-managed)
|
|
420
566
|
const globalPromptMatchesByPath = new Map(getLegacyGlobalPromptMatches(detection).map((prompt) => [prompt.path, prompt]));
|
|
421
567
|
for (const filePath of detection.globalSlashCommandFiles) {
|
|
@@ -461,7 +607,11 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
461
607
|
*/
|
|
462
608
|
export function formatCleanupSummary(result) {
|
|
463
609
|
const lines = [];
|
|
464
|
-
|
|
610
|
+
const keptFiles = result.keptFiles ?? [];
|
|
611
|
+
if (result.deletedFiles.length > 0 ||
|
|
612
|
+
result.deletedDirs.length > 0 ||
|
|
613
|
+
result.modifiedFiles.length > 0 ||
|
|
614
|
+
keptFiles.length > 0) {
|
|
465
615
|
lines.push('Cleaned up legacy files:');
|
|
466
616
|
for (const file of result.deletedFiles) {
|
|
467
617
|
const replacementLabel = result.deletedFileReplacementLabels?.[file]
|
|
@@ -474,6 +624,9 @@ export function formatCleanupSummary(result) {
|
|
|
474
624
|
for (const dir of result.deletedDirs) {
|
|
475
625
|
lines.push(` ✓ Removed ${dir}/ (replaced by OpenSpec skills and commands)`);
|
|
476
626
|
}
|
|
627
|
+
for (const entry of keptFiles) {
|
|
628
|
+
lines.push(` • Kept ${entry} (not created by OpenSpec)`);
|
|
629
|
+
}
|
|
477
630
|
for (const file of result.modifiedFiles) {
|
|
478
631
|
lines.push(` ✓ Removed OpenSpec markers from ${file}`);
|
|
479
632
|
}
|
|
@@ -643,8 +796,23 @@ function legacyToolIdForDir(dir) {
|
|
|
643
796
|
}
|
|
644
797
|
return undefined;
|
|
645
798
|
}
|
|
799
|
+
/** The files OpenSpec wrote into a repo-local legacy slash-command directory. */
|
|
800
|
+
function legacyManagedFileNamesForDir(dir) {
|
|
801
|
+
const normalizedDir = normalizePathForMatch(dir);
|
|
802
|
+
for (const pattern of Object.values(LEGACY_SLASH_COMMAND_PATHS)) {
|
|
803
|
+
if (pattern.type === 'directory' && pattern.path === normalizedDir) {
|
|
804
|
+
return pattern.managedFileNames ?? [];
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return [];
|
|
808
|
+
}
|
|
646
809
|
/** The tool that owns a repo-local legacy slash-command file, if any. */
|
|
647
810
|
function legacyToolIdForFile(file) {
|
|
811
|
+
// A file from a directory-based tool, reported because the directory also
|
|
812
|
+
// holds the user's own files.
|
|
813
|
+
const commandDir = legacyCommandDirForFile(file);
|
|
814
|
+
if (commandDir)
|
|
815
|
+
return commandDir.toolId;
|
|
648
816
|
// Normalize to forward slashes so the glob patterns match on Windows too.
|
|
649
817
|
const normalizedFile = normalizePathForMatch(file);
|
|
650
818
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
package/dist/core/migration.js
CHANGED
|
@@ -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();
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* tool — command, skill, or a tool-specific skill prefix — is decided by
|
|
10
10
|
* src/utils/command-references.ts at the call site.
|
|
11
11
|
*/
|
|
12
|
-
import type
|
|
12
|
+
import { type WorkflowId } from './profiles.js';
|
|
13
13
|
export type OnboardingCommand = {
|
|
14
14
|
workflow: WorkflowId;
|
|
15
15
|
command: string;
|
|
@@ -27,4 +27,14 @@ export declare const DESCRIPTION_BUDGET = 17;
|
|
|
27
27
|
* Returns an empty array when none of the onboarding workflows are installed.
|
|
28
28
|
*/
|
|
29
29
|
export declare function getOnboardingCommands(workflows: readonly string[]): OnboardingCommand[];
|
|
30
|
+
/**
|
|
31
|
+
* Returns the note telling a user which workflows their profile left out, or
|
|
32
|
+
* null when every workflow is already installed.
|
|
33
|
+
*
|
|
34
|
+
* Setup output otherwise never names the workflows that exist but were not
|
|
35
|
+
* installed, so a user on the default profile has no way to learn that
|
|
36
|
+
* `/opsx:ff` and friends are one command away. The docs say it; nobody reads
|
|
37
|
+
* the docs before typing a command that isn't there.
|
|
38
|
+
*/
|
|
39
|
+
export declare function formatOptionalWorkflowsNote(installedWorkflows: readonly string[]): string[] | null;
|
|
30
40
|
//# sourceMappingURL=onboarding-commands.d.ts.map
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* tool — command, skill, or a tool-specific skill prefix — is decided by
|
|
10
10
|
* src/utils/command-references.ts at the call site.
|
|
11
11
|
*/
|
|
12
|
+
import { ALL_WORKFLOWS } from './profiles.js';
|
|
12
13
|
/**
|
|
13
14
|
* Longest description the welcome screen can render. It shows these beside a
|
|
14
15
|
* 24-column art column and only animates at MIN_WIDTH (60) columns or wider; a
|
|
@@ -35,4 +36,29 @@ export function getOnboardingCommands(workflows) {
|
|
|
35
36
|
const installed = new Set(workflows);
|
|
36
37
|
return ONBOARDING_COMMANDS.filter((entry) => installed.has(entry.workflow));
|
|
37
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Returns the note telling a user which workflows their profile left out, or
|
|
41
|
+
* null when every workflow is already installed.
|
|
42
|
+
*
|
|
43
|
+
* Setup output otherwise never names the workflows that exist but were not
|
|
44
|
+
* installed, so a user on the default profile has no way to learn that
|
|
45
|
+
* `/opsx:ff` and friends are one command away. The docs say it; nobody reads
|
|
46
|
+
* the docs before typing a command that isn't there.
|
|
47
|
+
*/
|
|
48
|
+
export function formatOptionalWorkflowsNote(installedWorkflows) {
|
|
49
|
+
const installed = new Set(installedWorkflows);
|
|
50
|
+
const missing = ALL_WORKFLOWS.filter((workflow) => !installed.has(workflow));
|
|
51
|
+
if (missing.length === 0) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const label = missing.length === 1 ? 'workflow is' : 'workflows are';
|
|
55
|
+
const pronoun = missing.length === 1 ? 'it' : 'them';
|
|
56
|
+
// `openspec config profile` offers to apply to this project before it
|
|
57
|
+
// exits, and prints the `openspec update` guidance itself when declined, so
|
|
58
|
+
// naming a second command here would be one step too many.
|
|
59
|
+
return [
|
|
60
|
+
`Note: ${missing.length} more ${label} available (${missing.join(', ')}).`,
|
|
61
|
+
`Add ${pronoun} with \`openspec config profile\`.`,
|
|
62
|
+
];
|
|
63
|
+
}
|
|
38
64
|
//# sourceMappingURL=onboarding-commands.js.map
|
|
@@ -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
|
-
|
|
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
|
}
|