@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
@@ -1,15 +1,30 @@
1
1
  import { buildCodeFenceMask } from '../parsers/code-fence.js';
2
2
  import { PURPOSE_PLACEHOLDER_PREFIX, PURPOSE_PLACEHOLDER_SUFFIX } from './constants.js';
3
3
  /**
4
- * A `TBD` or `TODO` opening the Purpose. The lookahead keeps it off a longer
5
- * word that merely begins with those letters, like "TBDs" or "TODOs", while
6
- * still allowing the punctuation a marker is usually written with: `TODO:`,
7
- * `TBD -`. It rejects any letter, digit or combining mark rather than only the
8
- * ASCII ones `\b` knows about, because a Purpose is prose and prose is not
9
- * always written in Latin script - `TBD` followed by an Arabic-Indic digit is
10
- * as much a longer word as `TBDs` is.
4
+ * A `TBD` or `TODO` opening the Purpose.
5
+ *
6
+ * `WORD_END` keeps the marker off a longer word that merely begins with those
7
+ * letters, like "TBDs" or "TODOs", while still allowing the punctuation a
8
+ * marker is usually written with: `TODO:`, `TBD -`. It rejects any letter,
9
+ * digit or combining mark rather than only the ASCII ones `\b` knows about,
10
+ * because a Purpose is prose and prose is not always written in Latin script -
11
+ * `TBD` followed by an Arabic-Indic digit is as much a longer word as `TBDs`.
12
+ *
13
+ * Case is what separates the marker from the word. `TODO` shouted in capitals
14
+ * is the marker whatever follows it, so `TODO write this later` is still an
15
+ * unwritten Purpose. Written in any other case it is only a marker when
16
+ * punctuation or the end of the line says so, because `todo` is an extremely
17
+ * frequent sentence opener in Spanish ("Todo el...") and Portuguese ("Todo
18
+ * o..."), and ordinary prose in those languages is not a placeholder. That
19
+ * keeps the lowercase forms an agent really does leave behind - `todo - write
20
+ * this later`, `tbd.` - reported, without reading a Spanish sentence as one.
11
21
  */
12
- const LEADING_MARKER = /^(?:TBD|TODO)(?![\p{L}\p{N}\p{M}_])/iu;
22
+ const WORD_END = '(?![\\p{L}\\p{N}\\p{M}_])';
23
+ const MARKER_PUNCTUATION = '(?=[ \\t]*(?:$|\\n|[:\\-\u2013\u2014.,;()\\[\\]{}]))';
24
+ /** `TBD`/`TODO` in capitals: the marker, whatever follows it. */
25
+ const LEADING_MARKER_SHOUTED = new RegExp(`^(?:TBD|TODO)${WORD_END}`, 'u');
26
+ /** Any other case: a marker only when punctuation or the line end says so. */
27
+ const LEADING_MARKER_PUNCTUATED = new RegExp(`^(?:TBD|TODO)${WORD_END}${MARKER_PUNCTUATION}`, 'iu');
13
28
  const PURPOSE_HEADER = /^ {0,3}##(?!#)[ \t]+Purpose[ \t]*$/i;
14
29
  const TOP_LEVEL_HEADER = /^ {0,3}#{1,2}(?!#)[ \t]+/;
15
30
  /**
@@ -60,7 +75,7 @@ export function findPurposePlaceholderIssue(overview, content) {
60
75
  // that is nothing but a fenced block reduces to the same empty text here, and
61
76
  // is left to the brevity and empty-Purpose rules for the same reason.
62
77
  const prose = unfencedLines(overview).join('\n').trim();
63
- const leading = LEADING_MARKER.test(prose);
78
+ const leading = LEADING_MARKER_SHOUTED.test(prose) || LEADING_MARKER_PUNCTUATED.test(prose);
64
79
  if (!leading && generatedPlaceholderPrefixIndex(prose) === undefined)
65
80
  return null;
66
81
  // Which rule matched decides where the placeholder is, so the locator is told.
@@ -5,12 +5,12 @@ import { MarkdownParser } from '../parsers/markdown-parser.js';
5
5
  import { ChangeParser } from '../parsers/change-parser.js';
6
6
  import { findSpecUpdates, buildUpdatedSpec } from '../specs-apply.js';
7
7
  import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
8
- import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, findMissingCurrentScenarios, } from '../parsers/requirement-blocks.js';
8
+ import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, diffScenarioNames, describeScenarioBalance, } from '../parsers/requirement-blocks.js';
9
9
  import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, countEmptyScenarios, } from '../parsers/requirement-text.js';
10
10
  import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
11
11
  import { FileSystemUtils } from '../../utils/file-system.js';
12
12
  import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
13
- import { METADATA_FILENAME, readSkipSpecsMarker, resolveSchemaForChange, } from '../../utils/change-metadata.js';
13
+ import { METADATA_FILENAME, formatUnknownChangeMetadataKeysMessage, readSkipSpecsMarker, readUnknownChangeMetadataKeys, resolveSchemaForChange, } from '../../utils/change-metadata.js';
14
14
  import { resolveTaskFilesForChange } from '../../utils/task-progress.js';
15
15
  import { findTaskNumberingIssues } from './task-numbering.js';
16
16
  import { findMissingTaskCheckboxIssues } from './task-checkboxes.js';
@@ -416,6 +416,14 @@ export class Validator {
416
416
  if (marker.invalidReason) {
417
417
  issues.push({ level: 'ERROR', path: METADATA_FILENAME, message: this.formatInvalidMarkerMessage(marker.invalidReason) });
418
418
  }
419
+ const unknownMetadataKeys = readUnknownChangeMetadataKeys(changeDir);
420
+ if (unknownMetadataKeys.length > 0) {
421
+ issues.push({
422
+ level: 'WARNING',
423
+ path: METADATA_FILENAME,
424
+ message: formatUnknownChangeMetadataKeysMessage(unknownMetadataKeys),
425
+ });
426
+ }
419
427
  // ANY file under specs/ contradicts the marker - not just parsed deltas.
420
428
  // Headerless or stray files would be silently dropped at archive time (and
421
429
  // some still satisfy the artifact graph's specs/** glob) while the change
@@ -639,15 +647,16 @@ export class Validator {
639
647
  const current = currentBlockFor(key);
640
648
  if (!current)
641
649
  continue;
642
- const missing = findMissingCurrentScenarios(current, block);
643
- if (missing.length === 0)
650
+ const diff = diffScenarioNames(current, block);
651
+ if (diff.missing.length === 0)
644
652
  continue;
645
653
  issues.push({
646
654
  level: 'ERROR',
647
655
  path: entryPath,
648
656
  message: `MODIFIED "${block.name}" omits scenario(s) the current spec still has: ` +
649
- `${missing.map(name => `"${name}"`).join(', ')}. ` +
650
- 'Copy them into the MODIFIED block (a MODIFIED requirement replaces the whole block, so archive refuses to drop them).',
657
+ `${diff.missing.map(name => `"${name}"`).join(', ')}. ` +
658
+ `${describeScenarioBalance(diff)} ` +
659
+ 'Copy the omitted scenarios into the MODIFIED block (a MODIFIED requirement replaces the whole block, so archive refuses to drop them).',
651
660
  });
652
661
  }
653
662
  return issues;
@@ -14,9 +14,19 @@ export declare function registryUrl(): string | null;
14
14
  * otherwise. Prereleases sort below their release (1.7.0-beta.1 < 1.7.0).
15
15
  */
16
16
  export declare function compareVersions(a: string, b: string): number;
17
+ export type CliUpdateStatus = 'available' | 'current' | 'disabled' | 'offline';
18
+ export interface CliUpdateCheck {
19
+ status: CliUpdateStatus;
20
+ latest: string | null;
21
+ }
22
+ /**
23
+ * Checks the registry while preserving enough detail for callers to explain
24
+ * why no newer version was reported. Never throws.
25
+ */
26
+ export declare function checkForCliUpdate(): Promise<CliUpdateCheck>;
17
27
  /**
18
28
  * Returns the published version when the installed CLI is behind it, otherwise
19
- * null. Never throws and never blocks for longer than the request timeout.
29
+ * null. Kept as the compatibility surface used by `openspec update`.
20
30
  */
21
31
  export declare function getAvailableCliUpdate(): Promise<string | null>;
22
32
  /**
@@ -69,11 +79,23 @@ export declare function isNpmGlobalInstall(installDir: string | null, roots?: st
69
79
  */
70
80
  export declare function isSourceCheckout(installDir: string | null): boolean;
71
81
  export type PackageManager = 'npm' | 'pnpm' | 'bun' | 'yarn' | 'volta';
82
+ export type InstallScope = 'global' | 'project' | 'temporary' | 'source';
83
+ export interface CliInstallInfo {
84
+ location: string | null;
85
+ packageManager: PackageManager | null;
86
+ scope: InstallScope | null;
87
+ }
72
88
  /**
73
89
  * The package manager that owns this copy, so the printed command is one the
74
90
  * user's setup will actually honor.
75
91
  */
76
92
  export declare function detectPackageManager(installDir: string | null): PackageManager;
93
+ /** Describes the running copy without guessing when its owner is ambiguous. */
94
+ export declare function getCliInstallInfo(installDir?: string | null, projectPath?: string): CliInstallInfo;
95
+ /** Returns a safe update command only for an install with known global ownership. */
96
+ export declare function getCliUpdateCommand(install: CliInstallInfo): string | null;
97
+ /** Builds the concise human-readable form of the version report. */
98
+ export declare function buildVersionReportLines(version: string, install: CliInstallInfo, update?: CliUpdateCheck, command?: string | null): string[];
77
99
  /**
78
100
  * Builds the hint, with the upgrade command chosen for how this copy of the CLI
79
101
  * was installed. Pure so every branch is assertable.
@@ -6,6 +6,7 @@ import { createRequire } from 'module';
6
6
  import chalk from 'chalk';
7
7
  import { isCiEnvironment } from '../utils/ci.js';
8
8
  import { isTelemetryOptedOutByEnv } from '../telemetry/opt-out.js';
9
+ import { FileSystemUtils } from '../utils/file-system.js';
9
10
  import { getGlobalConfig, isGlobalConfigUnreadable } from './global-config.js';
10
11
  const require = createRequire(import.meta.url);
11
12
  const { name: PACKAGE_NAME, version: OPENSPEC_VERSION } = require('../../package.json');
@@ -268,22 +269,34 @@ function fetchLatestVersion() {
268
269
  });
269
270
  }
270
271
  /**
271
- * Returns the published version when the installed CLI is behind it, otherwise
272
- * null. Never throws and never blocks for longer than the request timeout.
272
+ * Checks the registry while preserving enough detail for callers to explain
273
+ * why no newer version was reported. Never throws.
273
274
  */
274
- export async function getAvailableCliUpdate() {
275
- if (!isCheckEnabled())
276
- return null;
275
+ export async function checkForCliUpdate() {
276
+ if (!isCheckEnabled() || registryUrl() === null) {
277
+ return { status: 'disabled', latest: null };
278
+ }
277
279
  try {
278
280
  const latest = await fetchLatestVersion();
279
281
  if (!latest)
280
- return null;
281
- return compareVersions(latest, OPENSPEC_VERSION) > 0 ? latest : null;
282
+ return { status: 'offline', latest: null };
283
+ return {
284
+ status: compareVersions(latest, OPENSPEC_VERSION) > 0 ? 'available' : 'current',
285
+ latest,
286
+ };
282
287
  }
283
288
  catch {
284
- return null;
289
+ return { status: 'offline', latest: null };
285
290
  }
286
291
  }
292
+ /**
293
+ * Returns the published version when the installed CLI is behind it, otherwise
294
+ * null. Kept as the compatibility surface used by `openspec update`.
295
+ */
296
+ export async function getAvailableCliUpdate() {
297
+ const result = await checkForCliUpdate();
298
+ return result.status === 'available' ? result.latest : null;
299
+ }
287
300
  /**
288
301
  * Directory the running CLI was loaded from, or null when it cannot be
289
302
  * resolved. Shown in the upgrade hint so anyone who upgraded but still runs an
@@ -312,8 +325,8 @@ export function isProjectLocalInstall(installDir, projectPath = '.') {
312
325
  // Windows paths differ in case and drive-letter casing between sources.
313
326
  const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
314
327
  try {
315
- let dir = path.resolve(projectPath);
316
- const target = normalize(installDir);
328
+ let dir = FileSystemUtils.canonicalizeExistingPath(projectPath);
329
+ const target = normalize(FileSystemUtils.canonicalizeExistingPath(installDir));
317
330
  for (;;) {
318
331
  if (target.startsWith(normalize(path.join(dir, 'node_modules') + path.sep))) {
319
332
  return true;
@@ -443,32 +456,29 @@ export function isSourceCheckout(installDir) {
443
456
  return false;
444
457
  }
445
458
  }
446
- /**
447
- * The package manager that owns this copy, so the printed command is one the
448
- * user's setup will actually honor.
449
- */
450
- export function detectPackageManager(installDir) {
451
- // Lowercased because the Windows directories are capitalized and undotted:
452
- // %LOCALAPPDATA%\\Volta, \\Yarn\\Data, \\pnpm-cache.
459
+ function detectKnownPackageManager(installDir) {
453
460
  const segments = (installDir ?? '').split(/[\\/]/).map((segment) => segment.toLowerCase());
454
461
  const has = (...names) => names.some((name) => segments.includes(name));
455
- // The undotted spelling exists for Windows (%LOCALAPPDATA%\Volta), whose
456
- // layout nests tools\image; require both segments so a user or project
457
- // directory merely named "volta" (even one with its own "tools" dir) does
458
- // not steal the install.
459
462
  if (has('.volta') || (has('volta') && has('tools') && has('image')))
460
463
  return 'volta';
461
- if (has('.bun'))
464
+ if (has('.bun', '_bunx', 'bun-cache'))
462
465
  return 'bun';
463
- // These two need a corroborating segment: a directory merely named "pnpm" or
464
- // "yarn" (a user's home, a project) is not a global install of one.
465
- if (has('.pnpm-global', 'pnpm-cache'))
466
+ if (has('_npx'))
467
+ return 'npm';
468
+ if (has('.pnpm', '.pnpm-global', 'pnpm-cache'))
466
469
  return 'pnpm';
467
470
  if (has('pnpm') && has('global', 'dlx', 'store'))
468
471
  return 'pnpm';
469
472
  if (has('.yarn') || (has('yarn') && has('global')))
470
473
  return 'yarn';
471
- return 'npm';
474
+ return null;
475
+ }
476
+ /**
477
+ * The package manager that owns this copy, so the printed command is one the
478
+ * user's setup will actually honor.
479
+ */
480
+ export function detectPackageManager(installDir) {
481
+ return detectKnownPackageManager(installDir) ?? 'npm';
472
482
  }
473
483
  const GLOBAL_UPGRADE_COMMANDS = {
474
484
  npm: `npm install -g ${PACKAGE_NAME}@latest`,
@@ -477,6 +487,82 @@ const GLOBAL_UPGRADE_COMMANDS = {
477
487
  yarn: `yarn global add ${PACKAGE_NAME}@latest`,
478
488
  volta: `volta install ${PACKAGE_NAME}@latest`,
479
489
  };
490
+ function detectGlobalPackageManager(installDir) {
491
+ if (isNpmGlobalInstall(installDir))
492
+ return 'npm';
493
+ if (!installDir)
494
+ return null;
495
+ const segments = installDir.split(/[\\/]/).map((segment) => segment.toLowerCase());
496
+ const has = (...names) => names.some((name) => segments.includes(name));
497
+ const hasSequence = (...names) => segments.some((_, index) => names.every((name, offset) => segments[index + offset] === name));
498
+ if (hasSequence('.volta', 'tools', 'image') || hasSequence('volta', 'tools', 'image')) {
499
+ return 'volta';
500
+ }
501
+ if (has('.pnpm-global') || hasSequence('pnpm', 'global'))
502
+ return 'pnpm';
503
+ if (hasSequence('yarn', 'global') || hasSequence('yarn', 'data', 'global'))
504
+ return 'yarn';
505
+ if (hasSequence('.bun', 'install', 'global'))
506
+ return 'bun';
507
+ return null;
508
+ }
509
+ /** Describes the running copy without guessing when its owner is ambiguous. */
510
+ export function getCliInstallInfo(installDir = getInstallDir(), projectPath = '.') {
511
+ if (!installDir) {
512
+ return { location: null, packageManager: null, scope: null };
513
+ }
514
+ if (isSourceCheckout(installDir)) {
515
+ return { location: installDir, packageManager: null, scope: 'source' };
516
+ }
517
+ if (isEphemeralRunnerInstall(installDir)) {
518
+ return {
519
+ location: installDir,
520
+ packageManager: detectKnownPackageManager(installDir),
521
+ scope: 'temporary',
522
+ };
523
+ }
524
+ if (isProjectLocalInstall(installDir, projectPath)) {
525
+ return {
526
+ location: installDir,
527
+ packageManager: detectKnownPackageManager(installDir),
528
+ scope: 'project',
529
+ };
530
+ }
531
+ const packageManager = detectGlobalPackageManager(installDir);
532
+ return {
533
+ location: installDir,
534
+ packageManager,
535
+ scope: packageManager ? 'global' : null,
536
+ };
537
+ }
538
+ /** Returns a safe update command only for an install with known global ownership. */
539
+ export function getCliUpdateCommand(install) {
540
+ if (install.scope !== 'global' || install.packageManager === null)
541
+ return null;
542
+ return GLOBAL_UPGRADE_COMMANDS[install.packageManager];
543
+ }
544
+ /** Builds the concise human-readable form of the version report. */
545
+ export function buildVersionReportLines(version, install, update, command = null) {
546
+ const details = [install.packageManager, install.scope].filter(Boolean).join(', ');
547
+ const lines = [`OpenSpec ${version}${details ? ` (${details})` : ''}`];
548
+ if (!update)
549
+ return lines;
550
+ if (update.status === 'available') {
551
+ lines.push(`Update available: ${update.latest}`);
552
+ if (command)
553
+ lines.push(` ${command}`);
554
+ }
555
+ else if (update.status === 'current') {
556
+ lines.push('OpenSpec is up to date.');
557
+ }
558
+ else if (update.status === 'disabled') {
559
+ lines.push('Update check disabled.');
560
+ }
561
+ else {
562
+ lines.push('Could not check for updates.');
563
+ }
564
+ return lines;
565
+ }
480
566
  /**
481
567
  * Builds the hint, with the upgrade command chosen for how this copy of the CLI
482
568
  * was installed. Pure so every branch is assertable.
@@ -3,6 +3,8 @@ export declare class ViewCommand {
3
3
  private getChangesData;
4
4
  private getSpecsData;
5
5
  private displaySummary;
6
+ private sanitizeWorkflowText;
7
+ private formatWorkflowArtifacts;
6
8
  private createProgressBar;
7
9
  }
8
10
  //# sourceMappingURL=view.d.ts.map
package/dist/core/view.js CHANGED
@@ -4,6 +4,7 @@ import chalk from 'chalk';
4
4
  import { getTaskProgressForChange } from '../utils/task-progress.js';
5
5
  import { MarkdownParser } from './parsers/markdown-parser.js';
6
6
  import { discoverSpecFiles } from '../utils/spec-discovery.js';
7
+ import { loadChangeContext, formatChangeStatus } from './artifact-graph/index.js';
7
8
  export class ViewCommand {
8
9
  async execute(targetPath = '.') {
9
10
  const openspecDir = path.join(targetPath, 'openspec');
@@ -30,12 +31,17 @@ export class ViewCommand {
30
31
  if (changesData.active.length > 0) {
31
32
  console.log(chalk.bold.cyan('\nActive Changes'));
32
33
  console.log('─'.repeat(60));
34
+ const maxNameLength = Math.min(48, Math.max(30, ...changesData.active.map((change) => change.name.length)));
33
35
  changesData.active.forEach((change) => {
34
36
  const progressBar = this.createProgressBar(change.progress.completed, change.progress.total);
35
37
  const percentage = change.progress.total > 0
36
38
  ? Math.round((change.progress.completed / change.progress.total) * 100)
37
39
  : 0;
38
- console.log(` ${chalk.yellow('◉')} ${chalk.bold(change.name.padEnd(30))} ${progressBar} ${chalk.dim(`${percentage}%`)}`);
40
+ console.log(` ${chalk.yellow('◉')} ${chalk.bold(change.name.padEnd(maxNameLength))} ${progressBar} ${chalk.dim(`${percentage}%`)}`);
41
+ if (change.workflowStatus) {
42
+ const { schemaName, artifacts } = change.workflowStatus;
43
+ console.log(` ${chalk.dim(`└─ [${this.sanitizeWorkflowText(schemaName)}]`)} ${this.formatWorkflowArtifacts(artifacts)}`);
44
+ }
39
45
  });
40
46
  }
41
47
  // Display completed changes
@@ -46,6 +52,14 @@ export class ViewCommand {
46
52
  console.log(` ${chalk.green('✓')} ${change.name}`);
47
53
  });
48
54
  }
55
+ // Display archived changes
56
+ if (changesData.archived.length > 0) {
57
+ console.log(chalk.bold.gray('\nArchived Changes'));
58
+ console.log('─'.repeat(60));
59
+ changesData.archived.forEach((change) => {
60
+ console.log(chalk.gray(` ◦ ${change.name}`));
61
+ });
62
+ }
49
63
  // Display specifications
50
64
  if (specsData.length > 0) {
51
65
  console.log(chalk.bold.blue('\nSpecifications'));
@@ -62,12 +76,27 @@ export class ViewCommand {
62
76
  }
63
77
  async getChangesData(openspecDir) {
64
78
  const changesDir = path.join(openspecDir, 'changes');
79
+ const projectRoot = path.dirname(openspecDir);
65
80
  if (!fs.existsSync(changesDir)) {
66
- return { draft: [], active: [], completed: [] };
81
+ return { draft: [], active: [], completed: [], archived: [] };
67
82
  }
68
83
  const draft = [];
69
84
  const active = [];
70
85
  const completed = [];
86
+ let archived = [];
87
+ try {
88
+ archived = fs.readdirSync(path.join(changesDir, 'archive'), { withFileTypes: true })
89
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
90
+ .map((entry) => ({ name: entry.name }));
91
+ }
92
+ catch (error) {
93
+ // A missing archive, or an `archive` path that is a file, has no archived
94
+ // changes to show; neither should break the rest of the dashboard.
95
+ const code = error.code;
96
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') {
97
+ throw error;
98
+ }
99
+ }
71
100
  const entries = fs.readdirSync(changesDir, { withFileTypes: true });
72
101
  for (const entry of entries) {
73
102
  if (entry.isDirectory() && entry.name !== 'archive') {
@@ -82,7 +111,15 @@ export class ViewCommand {
82
111
  }
83
112
  else {
84
113
  // Has tasks but not all complete
85
- active.push({ name: entry.name, progress });
114
+ let workflowStatus;
115
+ try {
116
+ workflowStatus = formatChangeStatus(loadChangeContext(projectRoot, entry.name));
117
+ }
118
+ catch (error) {
119
+ // Preserve task progress even when this change's workflow cannot be loaded.
120
+ console.warn(chalk.yellow(this.sanitizeWorkflowText(`Could not load workflow status for "${entry.name}": ${error instanceof Error ? error.message : String(error)}`)));
121
+ }
122
+ active.push({ name: entry.name, progress, workflowStatus });
86
123
  }
87
124
  }
88
125
  }
@@ -99,7 +136,8 @@ export class ViewCommand {
99
136
  return a.name.localeCompare(b.name);
100
137
  });
101
138
  completed.sort((a, b) => a.name.localeCompare(b.name));
102
- return { draft, active, completed };
139
+ archived.sort((a, b) => a.name.localeCompare(b.name));
140
+ return { draft, active, completed, archived };
103
141
  }
104
142
  async getSpecsData(openspecDir) {
105
143
  const specsDir = path.join(openspecDir, 'specs');
@@ -144,11 +182,31 @@ export class ViewCommand {
144
182
  }
145
183
  console.log(` ${chalk.yellow('●')} Active Changes: ${chalk.bold(changesData.active.length)} in progress`);
146
184
  console.log(` ${chalk.green('●')} Completed Changes: ${chalk.bold(changesData.completed.length)}`);
185
+ console.log(` ${chalk.gray('●')} Archived Changes: ${chalk.bold(changesData.archived.length)}`);
147
186
  if (totalTasks > 0) {
148
187
  const overallProgress = Math.round((completedTasks / totalTasks) * 100);
149
188
  console.log(` ${chalk.magenta('●')} Task Progress: ${chalk.bold(`${completedTasks}/${totalTasks}`)} (${overallProgress}% complete)`);
150
189
  }
151
190
  }
191
+ sanitizeWorkflowText(value) {
192
+ // Metadata may contain terminal controls; mask them before adding our own colors.
193
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, '?');
194
+ }
195
+ formatWorkflowArtifacts(artifacts) {
196
+ return artifacts.map((artifact) => {
197
+ const id = this.sanitizeWorkflowText(artifact.id);
198
+ switch (artifact.status) {
199
+ case 'done':
200
+ return `${id}${chalk.green('✓')}`;
201
+ case 'ready':
202
+ return `${id}${chalk.cyan('→')}`;
203
+ case 'skipped':
204
+ return chalk.dim(`${id} (skipped)`);
205
+ case 'blocked':
206
+ return chalk.dim(id);
207
+ }
208
+ }).join(' ');
209
+ }
152
210
  createProgressBar(completed, total, width = 20) {
153
211
  if (total === 0)
154
212
  return chalk.dim('─'.repeat(width));
@@ -1,6 +1,25 @@
1
- import { type ChangeMetadata } from '../core/change-metadata/index.js';
1
+ import { CHANGE_METADATA_KNOWN_KEYS, type ChangeMetadata } from '../core/change-metadata/index.js';
2
2
  import { type ProjectConfig } from '../core/project-config.js';
3
3
  export declare const METADATA_FILENAME = ".openspec.yaml";
4
+ export { CHANGE_METADATA_KNOWN_KEYS };
5
+ /**
6
+ * Unknown top-level keys on a parsed .openspec.yaml object. Extra keys are
7
+ * stripped by ChangeMetadataSchema rather than rejected, so callers that want
8
+ * to tell the author a key did nothing have to look at the raw object.
9
+ */
10
+ export declare function listUnknownChangeMetadataKeys(parsed: unknown): string[];
11
+ /**
12
+ * Human-readable warning for keys ChangeMetadataSchema strips. Names the
13
+ * unknown keys, the keys that do exist, and (when present) why `skip_design`
14
+ * is not `skip_specs`.
15
+ */
16
+ export declare function formatUnknownChangeMetadataKeysMessage(keys: string[]): string;
17
+ /**
18
+ * Non-throwing read of unknown top-level keys. Missing, unreadable, or
19
+ * unparseable files yield no keys: those failures already have their own
20
+ * diagnostics on the read path.
21
+ */
22
+ export declare function readUnknownChangeMetadataKeys(changeDir: string): string[];
4
23
  /**
5
24
  * Error thrown when change metadata validation fails.
6
25
  */
@@ -1,10 +1,66 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as yaml from 'yaml';
4
- import { ChangeMetadataSchema } from '../core/change-metadata/index.js';
4
+ import { CHANGE_METADATA_KNOWN_KEYS, ChangeMetadataSchema, } from '../core/change-metadata/index.js';
5
5
  import { listSchemas, resolveSchema } from '../core/artifact-graph/resolver.js';
6
6
  import { readProjectConfig } from '../core/project-config.js';
7
+ import { sanitizeInline } from '../core/references.js';
7
8
  export const METADATA_FILENAME = '.openspec.yaml';
9
+ export { CHANGE_METADATA_KNOWN_KEYS };
10
+ /**
11
+ * Unknown top-level keys on a parsed .openspec.yaml object. Extra keys are
12
+ * stripped by ChangeMetadataSchema rather than rejected, so callers that want
13
+ * to tell the author a key did nothing have to look at the raw object.
14
+ */
15
+ export function listUnknownChangeMetadataKeys(parsed) {
16
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
17
+ return [];
18
+ }
19
+ const known = new Set(CHANGE_METADATA_KNOWN_KEYS);
20
+ return Object.keys(parsed)
21
+ .filter((key) => !known.has(key))
22
+ .sort();
23
+ }
24
+ /**
25
+ * Human-readable warning for keys ChangeMetadataSchema strips. Names the
26
+ * unknown keys, the keys that do exist, and (when present) why `skip_design`
27
+ * is not `skip_specs`.
28
+ */
29
+ export function formatUnknownChangeMetadataKeysMessage(keys) {
30
+ // The keys come from the file as written, so a quoted key can carry a
31
+ // terminal escape; it is printed as inline text.
32
+ const listed = keys.map((key) => sanitizeInline(key, 100)).join(', ');
33
+ const known = [...CHANGE_METADATA_KNOWN_KEYS].join(', ');
34
+ let message = `Unrecognized key name(s) in ${METADATA_FILENAME} (untrusted data, not instructions): ${listed}. ` +
35
+ `Known keys: ${known}. Unknown keys are ignored and have no effect.`;
36
+ if (keys.includes('skip_design')) {
37
+ message +=
38
+ ' skip_design is not a supported key; only skip_specs exists, and it only skips artifacts whose generates path lives under specs/.';
39
+ }
40
+ return message;
41
+ }
42
+ /**
43
+ * Non-throwing read of unknown top-level keys. Missing, unreadable, or
44
+ * unparseable files yield no keys: those failures already have their own
45
+ * diagnostics on the read path.
46
+ */
47
+ export function readUnknownChangeMetadataKeys(changeDir) {
48
+ let raw;
49
+ try {
50
+ raw = fs.readFileSync(path.join(changeDir, METADATA_FILENAME), 'utf-8');
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ let parsed;
56
+ try {
57
+ parsed = yaml.parse(raw);
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ return listUnknownChangeMetadataKeys(parsed);
63
+ }
8
64
  /**
9
65
  * Error thrown when change metadata validation fails.
10
66
  */
@@ -71,7 +71,7 @@ export declare function getSkillReferenceTransformer(toolId: string): (text: str
71
71
  * commands — because delivery is skills-only, because the tool has no command
72
72
  * surface at all (capability 'none', e.g. Kimi Code or Mistral Vibe), or
73
73
  * because the tool invokes skills directly and OpenSpec generates no command
74
- * files for it (capability 'skills-invocable', i.e. Codex) — so those skills
74
+ * files for it (capability 'skills-invocable', e.g. Codex or Warp) — so those skills
75
75
  * never point at commands that were not generated.
76
76
  *
77
77
  * When commands are generated, the spelling follows the tool's invocation: a
@@ -67,7 +67,7 @@ const SKILL_INVOCATION_PREFIX = {
67
67
  * Skill references for these tools are spelled as prose ("the openspec-propose
68
68
  * skill") so skills-only delivery does not advertise unregistered commands.
69
69
  */
70
- const NATURAL_LANGUAGE_SKILL_TOOLS = new Set(['rovodev', 'codeassistant']);
70
+ const NATURAL_LANGUAGE_SKILL_TOOLS = new Set(['gsd', 'rovodev', 'codeassistant']);
71
71
  /**
72
72
  * Whether a tool references skills by natural language rather than a slash
73
73
  * command (see NATURAL_LANGUAGE_SKILL_TOOLS).
@@ -146,7 +146,7 @@ export function getSkillReferenceTransformer(toolId) {
146
146
  * commands — because delivery is skills-only, because the tool has no command
147
147
  * surface at all (capability 'none', e.g. Kimi Code or Mistral Vibe), or
148
148
  * because the tool invokes skills directly and OpenSpec generates no command
149
- * files for it (capability 'skills-invocable', i.e. Codex) — so those skills
149
+ * files for it (capability 'skills-invocable', e.g. Codex or Warp) — so those skills
150
150
  * never point at commands that were not generated.
151
151
  *
152
152
  * When commands are generated, the spelling follows the tool's invocation: a
@@ -1,5 +1,6 @@
1
1
  import * as nodeFs from 'fs';
2
2
  import path from 'path';
3
+ import { detectLineEnding, matchLineEnding } from './line-endings.js';
3
4
  const fs = nodeFs.promises;
4
5
  const { constants: fsConstants } = nodeFs;
5
6
  function hasOwnerGroupOrOtherWriteBit(stats) {
@@ -277,8 +278,14 @@ export class FileSystemUtils {
277
278
  }
278
279
  static async updateFileWithMarkers(filePath, content, startMarker, endMarker) {
279
280
  let existingContent = '';
281
+ // The managed block is composed with '\n', so splicing it into a CRLF file
282
+ // would leave mixed endings behind. bash reports a stray '\r' in .bashrc as
283
+ // "$'\r': command not found", so settle the whole file on the convention it
284
+ // already used. A file that does not exist yet stays LF.
285
+ let originalContent;
280
286
  if (await this.fileExists(filePath)) {
281
287
  existingContent = await this.readFile(filePath);
288
+ originalContent = existingContent;
282
289
  const startIndex = findMarkerIndex(existingContent, startMarker);
283
290
  const endIndex = startIndex !== -1
284
291
  ? findMarkerIndex(existingContent, endMarker, startIndex + startMarker.length)
@@ -301,7 +308,9 @@ export class FileSystemUtils {
301
308
  else {
302
309
  existingContent = startMarker + '\n' + content + '\n' + endMarker;
303
310
  }
304
- await this.writeFile(filePath, existingContent);
311
+ await this.writeFile(filePath, originalContent === undefined
312
+ ? existingContent
313
+ : matchLineEnding(existingContent, originalContent));
305
314
  }
306
315
  static async ensureWritePermissions(dirPath) {
307
316
  try {
@@ -377,14 +386,22 @@ export function removeMarkerBlock(content, startMarker, endMarker) {
377
386
  }
378
387
  const before = content.substring(0, lineStart);
379
388
  const after = content.substring(lineEnd);
389
+ // The file's own newline, used for every ending this function writes. The
390
+ // blank-line collapse below rebuilds the separator it matched, so spelling it
391
+ // '\n' would leave a CRLF file with a mixed pair wherever a run was collapsed
392
+ // - the stray '\r' that bash reports as "$'\r': command not found".
393
+ //
394
+ // Dominant rather than "contains a CRLF anywhere", so that one stray CRLF in
395
+ // an otherwise-LF file does not pull the whole rewrite to CRLF. This is the
396
+ // same reading matchLineEnding uses, so both write paths agree.
397
+ const newline = detectLineEnding(content) ?? '\n';
380
398
  // Clean up double blank lines (handle both Unix \n and Windows \r\n)
381
399
  let result = before + after;
382
- result = result.replace(/(\r?\n){3,}/g, '\n\n');
400
+ result = result.replace(/(\r?\n){3,}/g, newline + newline);
383
401
  // Trim trailing whitespace but preserve leading whitespace and original newline style
384
402
  if (result.trimEnd() === '') {
385
403
  return '';
386
404
  }
387
- const newline = content.includes('\r\n') ? '\r\n' : '\n';
388
405
  return result.trimEnd() + newline;
389
406
  }
390
407
  //# sourceMappingURL=file-system.js.map