@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.
- package/README.md +15 -3
- package/dist/cli/index.js +39 -1
- package/dist/commands/spec.js +1 -0
- package/dist/commands/workflow/instructions.js +66 -18
- package/dist/commands/workflow/shared.d.ts +7 -0
- package/dist/commands/workflow/status.js +15 -2
- package/dist/core/archive.js +258 -78
- package/dist/core/artifact-graph/instruction-loader.d.ts +7 -0
- package/dist/core/artifact-graph/instruction-loader.js +18 -1
- package/dist/core/artifact-graph/outputs.d.ts +1 -1
- package/dist/core/artifact-graph/outputs.js +38 -8
- package/dist/core/change-metadata/schema.d.ts +3 -0
- package/dist/core/change-metadata/schema.js +10 -0
- package/dist/core/change-status-policy.d.ts +8 -0
- package/dist/core/change-status-policy.js +33 -2
- package/dist/core/command-generation/adapters/atomcode.d.ts +24 -0
- package/dist/core/command-generation/adapters/atomcode.js +46 -0
- package/dist/core/command-generation/adapters/codestudio.d.ts +13 -0
- package/dist/core/command-generation/adapters/codestudio.js +27 -0
- package/dist/core/command-generation/adapters/continue.js +4 -0
- package/dist/core/command-generation/adapters/easycode.d.ts +17 -0
- package/dist/core/command-generation/adapters/easycode.js +28 -0
- package/dist/core/command-generation/adapters/gemini.js +1 -35
- package/dist/core/command-generation/adapters/gigacode.d.ts +17 -0
- package/dist/core/command-generation/adapters/gigacode.js +31 -0
- package/dist/core/command-generation/adapters/index.d.ts +4 -0
- package/dist/core/command-generation/adapters/index.js +4 -0
- package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
- package/dist/core/command-generation/adapters/kilocode.js +2 -2
- package/dist/core/command-generation/registry.js +8 -0
- package/dist/core/command-generation/toml.d.ts +19 -0
- package/dist/core/command-generation/toml.js +39 -0
- package/dist/core/command-surface.js +1 -1
- package/dist/core/completions/command-registry.js +19 -0
- package/dist/core/completions/installers/zsh-installer.js +4 -2
- package/dist/core/config-prompts.js +6 -6
- package/dist/core/config.js +11 -1
- package/dist/core/init.js +44 -14
- package/dist/core/legacy-cleanup.d.ts +1 -1
- package/dist/core/legacy-cleanup.js +40 -11
- package/dist/core/list.d.ts +2 -0
- package/dist/core/list.js +49 -23
- package/dist/core/parsers/markdown-parser.js +5 -0
- package/dist/core/parsers/requirement-blocks.d.ts +40 -3
- package/dist/core/parsers/requirement-blocks.js +74 -25
- package/dist/core/project-config.js +45 -1
- package/dist/core/references.js +3 -1
- package/dist/core/root-selection.d.ts +6 -0
- package/dist/core/root-selection.js +16 -0
- package/dist/core/schemas/base.schema.d.ts +3 -0
- package/dist/core/schemas/base.schema.js +6 -0
- package/dist/core/schemas/change.schema.d.ts +8 -0
- package/dist/core/schemas/spec.schema.d.ts +2 -0
- package/dist/core/specs-apply.d.ts +20 -0
- package/dist/core/specs-apply.js +19 -6
- package/dist/core/templates/workflows/apply-change.js +5 -2
- package/dist/core/templates/workflows/archive-change.js +56 -20
- package/dist/core/templates/workflows/bulk-archive-change.js +34 -16
- package/dist/core/templates/workflows/continue-change.js +0 -2
- package/dist/core/templates/workflows/explore.js +2 -2
- package/dist/core/templates/workflows/ff-change.js +2 -2
- package/dist/core/templates/workflows/onboard.js +9 -4
- package/dist/core/templates/workflows/update-change.js +21 -9
- package/dist/core/templates/workflows/verify-change.js +115 -52
- package/dist/core/update.js +20 -7
- package/dist/core/validation/constants.d.ts +1 -1
- package/dist/core/validation/constants.js +1 -1
- package/dist/core/validation/purpose-placeholder.js +24 -9
- package/dist/core/validation/validator.js +15 -6
- package/dist/core/version-check.d.ts +23 -1
- package/dist/core/version-check.js +112 -26
- package/dist/core/view.d.ts +2 -0
- package/dist/core/view.js +62 -4
- package/dist/utils/change-metadata.d.ts +20 -1
- package/dist/utils/change-metadata.js +57 -1
- package/dist/utils/command-references.d.ts +1 -1
- package/dist/utils/command-references.js +2 -2
- package/dist/utils/file-system.js +20 -3
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/line-endings.d.ts +34 -0
- package/dist/utils/line-endings.js +46 -0
- package/package.json +2 -2
- package/schemas/spec-driven/schema.yaml +10 -1
- 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.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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 =
|
|
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,
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
272
|
-
*
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
464
|
-
|
|
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
|
|
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.
|
package/dist/core/view.d.ts
CHANGED
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(
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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',
|
|
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,
|
|
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,
|
|
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
|