@fission-ai/openspec 1.9.0 → 1.11.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/dist/cli/index.d.ts +17 -0
- package/dist/cli/index.js +60 -9
- package/dist/commands/change.d.ts +24 -0
- package/dist/commands/change.js +223 -0
- package/dist/commands/config.js +14 -3
- package/dist/commands/feedback.js +28 -7
- package/dist/commands/schema.d.ts +5 -0
- package/dist/commands/schema.js +201 -35
- package/dist/commands/show.js +1 -1
- package/dist/commands/workflow/index.d.ts +1 -1
- package/dist/commands/workflow/index.js +1 -1
- package/dist/commands/workflow/status.d.ts +2 -0
- package/dist/commands/workflow/status.js +76 -12
- package/dist/core/archive.js +64 -7
- package/dist/core/artifact-graph/instruction-loader.js +2 -7
- package/dist/core/artifact-graph/outputs.d.ts +4 -0
- package/dist/core/artifact-graph/outputs.js +7 -0
- package/dist/core/artifact-graph/resolver.js +15 -9
- package/dist/core/available-tools.js +16 -1
- package/dist/core/command-generation/adapters/antigravity.d.ts +1 -1
- package/dist/core/command-generation/adapters/antigravity.js +2 -2
- package/dist/core/command-generation/adapters/opencode.d.ts +2 -1
- package/dist/core/command-generation/adapters/opencode.js +13 -2
- package/dist/core/completion-tip.d.ts +15 -0
- package/dist/core/completion-tip.js +133 -0
- package/dist/core/completions/command-registry.js +20 -0
- package/dist/core/completions/factory.d.ts +11 -0
- package/dist/core/completions/generators/fish-generator.d.ts +12 -0
- package/dist/core/completions/generators/fish-generator.js +79 -47
- package/dist/core/completions/installers/bash-installer.d.ts +7 -0
- package/dist/core/completions/installers/bash-installer.js +15 -0
- package/dist/core/completions/installers/fish-installer.d.ts +7 -0
- package/dist/core/completions/installers/fish-installer.js +15 -0
- package/dist/core/completions/installers/powershell-installer.d.ts +7 -0
- package/dist/core/completions/installers/powershell-installer.js +15 -0
- package/dist/core/completions/templates/fish-templates.d.ts +1 -1
- package/dist/core/completions/templates/fish-templates.js +107 -10
- package/dist/core/completions/types.d.ts +4 -0
- package/dist/core/config-prompts.js +19 -10
- package/dist/core/config-schema.d.ts +1 -0
- package/dist/core/config-schema.js +2 -0
- package/dist/core/config.js +7 -1
- package/dist/core/global-config.d.ts +2 -0
- package/dist/core/init.d.ts +5 -0
- package/dist/core/init.js +117 -14
- package/dist/core/legacy-cleanup.js +6 -0
- package/dist/core/migration.js +42 -12
- package/dist/core/parsers/requirement-blocks.d.ts +1 -0
- package/dist/core/parsers/requirement-blocks.js +2 -0
- package/dist/core/profiles.d.ts +1 -1
- package/dist/core/profiles.js +11 -2
- package/dist/core/shared/tool-detection.js +9 -3
- package/dist/core/shared-skill-target.d.ts +13 -0
- package/dist/core/shared-skill-target.js +43 -1
- package/dist/core/specs-apply.js +16 -8
- package/dist/core/templates/workflows/explore.js +79 -73
- package/dist/core/templates/workflows/feedback.js +1 -0
- package/dist/core/templates/workflows/onboard.js +4 -4
- package/dist/core/update.js +39 -26
- package/dist/core/validation/constants.d.ts +3 -0
- package/dist/core/validation/constants.js +12 -0
- package/dist/core/validation/purpose-placeholder.d.ts +45 -0
- package/dist/core/validation/purpose-placeholder.js +117 -0
- package/dist/core/validation/validator.js +16 -1
- package/dist/telemetry/index.js +3 -2
- package/dist/utils/change-metadata.js +17 -13
- package/dist/utils/change-utils.js +5 -0
- package/dist/utils/requirement-diff.d.ts +42 -0
- package/dist/utils/requirement-diff.js +61 -0
- package/package.json +4 -6
- package/schemas/spec-driven/schema.yaml +17 -7
- package/scripts/postinstall.js +0 -83
package/dist/commands/schema.js
CHANGED
|
@@ -2,9 +2,10 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import ora from 'ora';
|
|
5
|
-
import { stringify as stringifyYaml, parseDocument } from 'yaml';
|
|
5
|
+
import { stringify as stringifyYaml, parseDocument, isMap } from 'yaml';
|
|
6
6
|
import { getSchemaDir, getProjectSchemasDir, getUserSchemasDir, getPackageSchemasDir, isSchemaDir, listSchemas, } from '../core/artifact-graph/resolver.js';
|
|
7
7
|
import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js';
|
|
8
|
+
import { resolveConfigFilePath } from '../core/project-config.js';
|
|
8
9
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
9
10
|
/**
|
|
10
11
|
* Check all three locations for a schema and return which ones exist.
|
|
@@ -289,6 +290,65 @@ function fingerprintDir(dir) {
|
|
|
289
290
|
walk(dir, '');
|
|
290
291
|
return hash.digest('hex');
|
|
291
292
|
}
|
|
293
|
+
/** @internal File-operation seam for transactional failure tests. */
|
|
294
|
+
export const schemaInitFileOperations = {
|
|
295
|
+
renameSync: fs.renameSync,
|
|
296
|
+
};
|
|
297
|
+
async function prepareDefaultConfigUpdate(projectRoot, schemaName) {
|
|
298
|
+
const configPath = resolveConfigFilePath(projectRoot) ??
|
|
299
|
+
path.join(projectRoot, 'openspec', 'config.yaml');
|
|
300
|
+
FileSystemUtils.assertProjectArtifactPath(projectRoot, configPath);
|
|
301
|
+
if (fs.existsSync(configPath)) {
|
|
302
|
+
const stats = fs.lstatSync(configPath);
|
|
303
|
+
if (stats.isSymbolicLink()) {
|
|
304
|
+
throw new Error(`Cannot set the default schema: ${path.basename(configPath)} must be a regular file, not a symbolic link`);
|
|
305
|
+
}
|
|
306
|
+
if (!stats.isFile()) {
|
|
307
|
+
throw new Error(`Cannot set the default schema: ${path.basename(configPath)} must be a regular file`);
|
|
308
|
+
}
|
|
309
|
+
if (!(await FileSystemUtils.canWriteFile(configPath)) ||
|
|
310
|
+
!(await FileSystemUtils.canWriteFile(path.dirname(configPath)))) {
|
|
311
|
+
throw new Error(`Cannot set the default schema: ${path.basename(configPath)} is not writable`);
|
|
312
|
+
}
|
|
313
|
+
const originalContent = fs.readFileSync(configPath);
|
|
314
|
+
const config = parseDocument(originalContent.toString('utf-8'));
|
|
315
|
+
if (config.errors.length > 0) {
|
|
316
|
+
throw new Error(`Cannot set the default schema: ${path.basename(configPath)} is invalid YAML`);
|
|
317
|
+
}
|
|
318
|
+
if (config.contents !== null && !isMap(config.contents)) {
|
|
319
|
+
throw new Error(`Cannot set the default schema: ${path.basename(configPath)} must contain a YAML object`);
|
|
320
|
+
}
|
|
321
|
+
config.set('schema', schemaName);
|
|
322
|
+
config.delete('defaultSchema');
|
|
323
|
+
return {
|
|
324
|
+
path: configPath,
|
|
325
|
+
content: Buffer.from(config.toString()),
|
|
326
|
+
originalContent,
|
|
327
|
+
originalMode: stats.mode,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (!(await FileSystemUtils.canWriteFile(configPath))) {
|
|
331
|
+
throw new Error(`Cannot set the default schema: ${path.dirname(configPath)} is not writable`);
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
path: configPath,
|
|
335
|
+
content: Buffer.from(stringifyYaml({ schema: schemaName })),
|
|
336
|
+
originalContent: null,
|
|
337
|
+
originalMode: null,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function configMatchesPreparedState(prepared) {
|
|
341
|
+
if (prepared.originalContent === null) {
|
|
342
|
+
return !fs.existsSync(prepared.path);
|
|
343
|
+
}
|
|
344
|
+
if (!fs.existsSync(prepared.path))
|
|
345
|
+
return false;
|
|
346
|
+
const stats = fs.lstatSync(prepared.path);
|
|
347
|
+
return (stats.isFile() &&
|
|
348
|
+
!stats.isSymbolicLink() &&
|
|
349
|
+
stats.mode === prepared.originalMode &&
|
|
350
|
+
fs.readFileSync(prepared.path).equals(prepared.originalContent));
|
|
351
|
+
}
|
|
292
352
|
/**
|
|
293
353
|
* Default artifacts with descriptions for schema init.
|
|
294
354
|
*/
|
|
@@ -952,46 +1012,152 @@ export function registerSchemaCommand(program) {
|
|
|
952
1012
|
tracks: 'tasks.md',
|
|
953
1013
|
};
|
|
954
1014
|
}
|
|
955
|
-
//
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1015
|
+
// Parse and serialize the config before staging any schema files. This
|
|
1016
|
+
// makes malformed, non-object, linked, and read-only configs fail before
|
|
1017
|
+
// an existing schema can be moved or a new one can appear.
|
|
1018
|
+
const preparedConfig = options?.default
|
|
1019
|
+
? await prepareDefaultConfigUpdate(projectRoot, name)
|
|
1020
|
+
: null;
|
|
1021
|
+
const schemasDir = getProjectSchemasDir(projectRoot);
|
|
1022
|
+
FileSystemUtils.assertProjectArtifactPath(projectRoot, schemaDir);
|
|
1023
|
+
const authorizedSchemaFingerprint = schemaExists
|
|
1024
|
+
? fingerprintDir(schemaDir)
|
|
1025
|
+
: null;
|
|
962
1026
|
if (spinner)
|
|
963
1027
|
spinner.start(`Creating schema '${name}'...`);
|
|
964
|
-
fs.mkdirSync(
|
|
965
|
-
fs.
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
const
|
|
971
|
-
|
|
972
|
-
|
|
1028
|
+
fs.mkdirSync(schemasDir, { recursive: true });
|
|
1029
|
+
const schemaStagingDir = fs.mkdtempSync(path.join(schemasDir, '.init-staging-'));
|
|
1030
|
+
let configStagingDir = null;
|
|
1031
|
+
let stagedConfigPath = null;
|
|
1032
|
+
try {
|
|
1033
|
+
fs.writeFileSync(path.join(schemaStagingDir, 'schema.yaml'), stringifyYaml(schema));
|
|
1034
|
+
const templatesDir = path.join(schemaStagingDir, 'templates');
|
|
1035
|
+
for (const artifact of selectedArtifacts) {
|
|
1036
|
+
const templatePath = path.join(templatesDir, artifact.template);
|
|
1037
|
+
fs.mkdirSync(path.dirname(templatePath), { recursive: true });
|
|
1038
|
+
fs.writeFileSync(templatePath, createDefaultTemplate(artifact.id));
|
|
1039
|
+
}
|
|
1040
|
+
const validation = validateSchema(schemaStagingDir);
|
|
1041
|
+
if (!validation.valid) {
|
|
1042
|
+
throw new Error(`Generated schema failed validation: ${validation.issues
|
|
1043
|
+
.map((issue) => issue.message)
|
|
1044
|
+
.join('; ')}`);
|
|
1045
|
+
}
|
|
1046
|
+
if (preparedConfig) {
|
|
1047
|
+
const configDir = path.dirname(preparedConfig.path);
|
|
1048
|
+
configStagingDir = fs.mkdtempSync(path.join(configDir, '.schema-init-config-'));
|
|
1049
|
+
stagedConfigPath = path.join(configStagingDir, path.basename(preparedConfig.path));
|
|
1050
|
+
fs.writeFileSync(stagedConfigPath, preparedConfig.content);
|
|
1051
|
+
if (preparedConfig.originalMode !== null) {
|
|
1052
|
+
fs.chmodSync(stagedConfigPath, preparedConfig.originalMode);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
// Re-resolve both destinations immediately before the first move so
|
|
1056
|
+
// a parent symlink swap during staging cannot redirect the commit.
|
|
1057
|
+
FileSystemUtils.assertProjectArtifactPath(projectRoot, schemaDir);
|
|
1058
|
+
if (preparedConfig) {
|
|
1059
|
+
FileSystemUtils.assertProjectArtifactPath(projectRoot, preparedConfig.path);
|
|
1060
|
+
}
|
|
1061
|
+
const currentSchemaFingerprint = fs.existsSync(schemaDir)
|
|
1062
|
+
? fingerprintDir(schemaDir)
|
|
1063
|
+
: null;
|
|
1064
|
+
if (currentSchemaFingerprint !== authorizedSchemaFingerprint) {
|
|
1065
|
+
throw new Error(`Schema '${name}' changed on disk while initialization was being prepared. ` +
|
|
1066
|
+
'Aborted to preserve those concurrent changes.');
|
|
1067
|
+
}
|
|
1068
|
+
if (preparedConfig && !configMatchesPreparedState(preparedConfig)) {
|
|
1069
|
+
throw new Error(`${path.basename(preparedConfig.path)} changed on disk while initialization was being prepared. ` +
|
|
1070
|
+
'Aborted to preserve those concurrent changes.');
|
|
1071
|
+
}
|
|
1072
|
+
const token = `${process.pid}-${Date.now()}`;
|
|
1073
|
+
const schemaBackup = `${schemaDir}.init-backup-${token}`;
|
|
1074
|
+
const configBackup = preparedConfig
|
|
1075
|
+
? `${preparedConfig.path}.init-backup-${token}`
|
|
1076
|
+
: null;
|
|
1077
|
+
let schemaBackedUp = false;
|
|
1078
|
+
let configBackedUp = false;
|
|
1079
|
+
let schemaInstalled = false;
|
|
1080
|
+
let configInstalled = false;
|
|
1081
|
+
try {
|
|
1082
|
+
if (schemaExists) {
|
|
1083
|
+
schemaInitFileOperations.renameSync(schemaDir, schemaBackup);
|
|
1084
|
+
schemaBackedUp = true;
|
|
1085
|
+
}
|
|
1086
|
+
if (preparedConfig && preparedConfig.originalContent !== null) {
|
|
1087
|
+
schemaInitFileOperations.renameSync(preparedConfig.path, configBackup);
|
|
1088
|
+
configBackedUp = true;
|
|
1089
|
+
}
|
|
1090
|
+
schemaInitFileOperations.renameSync(schemaStagingDir, schemaDir);
|
|
1091
|
+
schemaInstalled = true;
|
|
1092
|
+
if (preparedConfig && stagedConfigPath) {
|
|
1093
|
+
schemaInitFileOperations.renameSync(stagedConfigPath, preparedConfig.path);
|
|
1094
|
+
configInstalled = true;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
catch (installError) {
|
|
1098
|
+
const rollbackErrors = [];
|
|
1099
|
+
try {
|
|
1100
|
+
if (configInstalled && preparedConfig) {
|
|
1101
|
+
fs.rmSync(preparedConfig.path, { force: true });
|
|
1102
|
+
}
|
|
1103
|
+
if (configBackedUp && preparedConfig && configBackup) {
|
|
1104
|
+
schemaInitFileOperations.renameSync(configBackup, preparedConfig.path);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
catch (rollbackError) {
|
|
1108
|
+
rollbackErrors.push(`config: ${rollbackError.message}`);
|
|
1109
|
+
}
|
|
1110
|
+
try {
|
|
1111
|
+
if (schemaInstalled) {
|
|
1112
|
+
fs.rmSync(schemaDir, { recursive: true, force: true });
|
|
1113
|
+
}
|
|
1114
|
+
if (schemaBackedUp) {
|
|
1115
|
+
schemaInitFileOperations.renameSync(schemaBackup, schemaDir);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
catch (rollbackError) {
|
|
1119
|
+
rollbackErrors.push(`schema: ${rollbackError.message}`);
|
|
1120
|
+
}
|
|
1121
|
+
if (rollbackErrors.length > 0) {
|
|
1122
|
+
throw new Error(`Schema initialization failed and rollback was incomplete (${rollbackErrors.join(', ')}). ` +
|
|
1123
|
+
`Recovery backups may remain beside ${schemaDir} and ${preparedConfig?.path ?? 'the config file'}.`, { cause: installError });
|
|
1124
|
+
}
|
|
1125
|
+
throw installError;
|
|
1126
|
+
}
|
|
1127
|
+
// The transaction is committed. Cleanup cannot turn success into a
|
|
1128
|
+
// false failure, so leave a recoverable backup and warn if removal is
|
|
1129
|
+
// blocked instead of reporting that initialization failed.
|
|
1130
|
+
for (const backup of [
|
|
1131
|
+
schemaBackedUp ? schemaBackup : null,
|
|
1132
|
+
configBackedUp ? configBackup : null,
|
|
1133
|
+
]) {
|
|
1134
|
+
if (!backup)
|
|
1135
|
+
continue;
|
|
1136
|
+
try {
|
|
1137
|
+
fs.rmSync(backup, { recursive: true, force: true });
|
|
1138
|
+
}
|
|
1139
|
+
catch (cleanupError) {
|
|
1140
|
+
console.error(`Warning: initialization succeeded, but the backup at ${backup} could not be removed: ${cleanupError.message}`);
|
|
1141
|
+
}
|
|
973
1142
|
}
|
|
974
|
-
// Create default template content
|
|
975
|
-
const templateContent = createDefaultTemplate(artifact.id);
|
|
976
|
-
fs.writeFileSync(templatePath, templateContent);
|
|
977
1143
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
if (fs.existsSync(configPath)) {
|
|
982
|
-
const { parse: parseYaml, stringify: stringifyYaml2 } = await import('yaml');
|
|
983
|
-
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
984
|
-
const config = parseYaml(configContent) || {};
|
|
985
|
-
config.defaultSchema = name;
|
|
986
|
-
fs.writeFileSync(configPath, stringifyYaml2(config));
|
|
1144
|
+
catch (error) {
|
|
1145
|
+
try {
|
|
1146
|
+
fs.rmSync(schemaStagingDir, { recursive: true, force: true });
|
|
987
1147
|
}
|
|
988
|
-
|
|
989
|
-
//
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1148
|
+
catch {
|
|
1149
|
+
// Best-effort cleanup must not hide the operation's real error.
|
|
1150
|
+
}
|
|
1151
|
+
throw error;
|
|
1152
|
+
}
|
|
1153
|
+
finally {
|
|
1154
|
+
if (configStagingDir) {
|
|
1155
|
+
try {
|
|
1156
|
+
fs.rmSync(configStagingDir, { recursive: true, force: true });
|
|
1157
|
+
}
|
|
1158
|
+
catch {
|
|
1159
|
+
// Best-effort cleanup. A committed config has already moved out.
|
|
993
1160
|
}
|
|
994
|
-
fs.writeFileSync(configPath, stringifyYaml({ defaultSchema: name }));
|
|
995
1161
|
}
|
|
996
1162
|
}
|
|
997
1163
|
if (spinner)
|
package/dist/commands/show.js
CHANGED
|
@@ -4,7 +4,7 @@ import { resolveRootForCommand, toRootOutput, withStoreFlag, isStoreSelectedRoot
|
|
|
4
4
|
import { ChangeCommand } from './change.js';
|
|
5
5
|
import { SpecCommand } from './spec.js';
|
|
6
6
|
import { nearestMatches } from '../utils/match.js';
|
|
7
|
-
const CHANGE_FLAG_KEYS = new Set(['deltasOnly', 'requirementsOnly']);
|
|
7
|
+
const CHANGE_FLAG_KEYS = new Set(['deltasOnly', 'requirementsOnly', 'diff']);
|
|
8
8
|
const SPEC_FLAG_KEYS = new Set(['requirements', 'scenarios', 'requirement']);
|
|
9
9
|
export class ShowCommand {
|
|
10
10
|
async execute(itemName, options = {}) {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Commands for the artifact-driven workflow: status, instructions, templates, schemas, new change.
|
|
5
5
|
*/
|
|
6
|
-
export { statusCommand } from './status.js';
|
|
6
|
+
export { statusCommand, BATCH_STATUS_FAILURE_PAYLOAD } from './status.js';
|
|
7
7
|
export type { StatusOptions } from './status.js';
|
|
8
8
|
export { instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, } from './instructions.js';
|
|
9
9
|
export type { InstructionsOptions } from './instructions.js';
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Commands for the artifact-driven workflow: status, instructions, templates, schemas, new change.
|
|
5
5
|
*/
|
|
6
|
-
export { statusCommand } from './status.js';
|
|
6
|
+
export { statusCommand, BATCH_STATUS_FAILURE_PAYLOAD } from './status.js';
|
|
7
7
|
export { instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, } from './instructions.js';
|
|
8
8
|
export { templatesCommand } from './templates.js';
|
|
9
9
|
export { schemasCommand } from './schemas.js';
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
import { type ChangeStatus } from '../../core/artifact-graph/index.js';
|
|
7
7
|
export interface StatusOptions {
|
|
8
8
|
change?: string;
|
|
9
|
+
all?: boolean;
|
|
9
10
|
schema?: string;
|
|
10
11
|
store?: string;
|
|
11
12
|
storePath?: string;
|
|
12
13
|
json?: boolean;
|
|
13
14
|
}
|
|
15
|
+
export declare const BATCH_STATUS_FAILURE_PAYLOAD: Record<string, unknown>;
|
|
14
16
|
export declare function statusCommand(options: StatusOptions): Promise<void>;
|
|
15
17
|
export declare function printStatusText(status: ChangeStatus): void;
|
|
16
18
|
//# sourceMappingURL=status.d.ts.map
|
|
@@ -8,14 +8,26 @@ import chalk from 'chalk';
|
|
|
8
8
|
import { getChangeDir } from '../../core/planning-home.js';
|
|
9
9
|
import { resolveRootForCommand, toPlanningHome, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from '../../core/root-selection.js';
|
|
10
10
|
import { loadChangeContext, formatChangeStatus, } from '../../core/artifact-graph/index.js';
|
|
11
|
+
import { asStatus } from '../shared-output.js';
|
|
11
12
|
import { validateChangeExists, validateSchemaExists, getAvailableChanges, getStatusIndicator, getStatusColor, } from './shared.js';
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
13
|
+
// The --all --json failure null-shape. Root-selection failures (handled in
|
|
14
|
+
// resolveRootForCommand) and thrown errors (caught by the CLI wrapper) must
|
|
15
|
+
// emit the same shape, so both call sites reference this one constant.
|
|
16
|
+
export const BATCH_STATUS_FAILURE_PAYLOAD = {
|
|
17
|
+
changes: [],
|
|
18
|
+
root: null,
|
|
19
|
+
};
|
|
15
20
|
export async function statusCommand(options) {
|
|
21
|
+
if (options.all && options.change) {
|
|
22
|
+
throw new Error('The --all and --change options are mutually exclusive.');
|
|
23
|
+
}
|
|
16
24
|
// The root resolves (and the store banner prints) before the spinner starts
|
|
17
|
-
// so the two do not fight over stderr.
|
|
18
|
-
|
|
25
|
+
// so the two do not fight over stderr. The batch null-shape rides along so
|
|
26
|
+
// a root-selection failure under --all --json still carries `changes: []`.
|
|
27
|
+
const root = await resolveRootForCommand(options, {
|
|
28
|
+
json: options.json,
|
|
29
|
+
failurePayload: options.all ? BATCH_STATUS_FAILURE_PAYLOAD : undefined,
|
|
30
|
+
});
|
|
19
31
|
if (!root) {
|
|
20
32
|
return;
|
|
21
33
|
}
|
|
@@ -25,9 +37,20 @@ export async function statusCommand(options) {
|
|
|
25
37
|
const projectRoot = root.path;
|
|
26
38
|
const rootOutput = toRootOutput(root);
|
|
27
39
|
const newChangeHint = withStoreFlag(root, 'openspec new change <name>');
|
|
40
|
+
// Single definition of "load one change's status" so the batch and
|
|
41
|
+
// single-change payloads can never drift apart.
|
|
42
|
+
const loadStatus = (changeName) => formatChangeStatus(loadChangeContext(projectRoot, changeName, options.schema, {
|
|
43
|
+
changeDir: getChangeDir(planningHome, changeName),
|
|
44
|
+
planningHome,
|
|
45
|
+
}), isStoreSelectedRoot(root) ? { storeId: root.storeId } : {});
|
|
28
46
|
// Handle no-changes case gracefully — status is informational,
|
|
29
47
|
// so "no changes" is a valid state, not an error.
|
|
30
48
|
if (!options.change) {
|
|
49
|
+
// Validate before the no-changes early return so a bogus --schema
|
|
50
|
+
// fails the same way whether or not any change exists yet.
|
|
51
|
+
if (options.all && options.schema) {
|
|
52
|
+
validateSchemaExists(options.schema, projectRoot);
|
|
53
|
+
}
|
|
31
54
|
const available = await getAvailableChanges(projectRoot, root.changesDir);
|
|
32
55
|
if (available.length === 0) {
|
|
33
56
|
spinner?.stop();
|
|
@@ -38,9 +61,54 @@ export async function statusCommand(options) {
|
|
|
38
61
|
console.log(`No active changes. Create one with: ${newChangeHint}`);
|
|
39
62
|
return;
|
|
40
63
|
}
|
|
41
|
-
|
|
64
|
+
if (options.all) {
|
|
65
|
+
// readdir order is platform-dependent; sort for deterministic output,
|
|
66
|
+
// with the same comparator validate --all uses so the two batch
|
|
67
|
+
// commands order a given change set identically.
|
|
68
|
+
const entries = [];
|
|
69
|
+
for (const changeName of available.sort((a, b) => a.localeCompare(b))) {
|
|
70
|
+
try {
|
|
71
|
+
entries.push(loadStatus(changeName));
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
// One malformed change must not blank the sweep; carry its
|
|
75
|
+
// diagnostic in place and keep going.
|
|
76
|
+
entries.push({ changeName, status: [asStatus(error, 'change_error')] });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
spinner?.stop();
|
|
80
|
+
const failed = entries.some((entry) => !('artifacts' in entry));
|
|
81
|
+
if (options.json) {
|
|
82
|
+
console.log(JSON.stringify({ changes: entries, root: rootOutput }, null, 2));
|
|
83
|
+
if (failed) {
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
entries.forEach((entry, index) => {
|
|
89
|
+
if (index > 0) {
|
|
90
|
+
console.log();
|
|
91
|
+
}
|
|
92
|
+
if ('artifacts' in entry) {
|
|
93
|
+
printStatusText(entry);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
console.log(chalk.red(`✗ ${entry.changeName}: ${entry.status[0]?.message}`));
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
// A partial load is still a failed command in both output modes;
|
|
100
|
+
// JSON callers can parse the complete envelope independently of
|
|
101
|
+
// the process exit code.
|
|
102
|
+
if (failed) {
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Changes exist but neither --change nor --all provided. Name --all
|
|
108
|
+
// here too: it is the other way to answer this prompt, and a caller
|
|
109
|
+
// who wants every change should not have to find it in --help.
|
|
42
110
|
spinner?.stop();
|
|
43
|
-
throw new Error(`Missing required option --change. Available changes:\n ${available.join('\n ')}`);
|
|
111
|
+
throw new Error(`Missing required option --change (or --all for every active change). Available changes:\n ${available.join('\n ')}`);
|
|
44
112
|
}
|
|
45
113
|
const changeName = await validateChangeExists(options.change, projectRoot, root.changesDir, { newChangeHint });
|
|
46
114
|
// Validate schema if explicitly provided
|
|
@@ -48,11 +116,7 @@ export async function statusCommand(options) {
|
|
|
48
116
|
validateSchemaExists(options.schema, projectRoot);
|
|
49
117
|
}
|
|
50
118
|
// loadChangeContext will auto-detect schema from metadata if not provided
|
|
51
|
-
const
|
|
52
|
-
changeDir: getChangeDir(planningHome, changeName),
|
|
53
|
-
planningHome,
|
|
54
|
-
});
|
|
55
|
-
const status = formatChangeStatus(context, isStoreSelectedRoot(root) ? { storeId: root.storeId } : {});
|
|
119
|
+
const status = loadStatus(changeName);
|
|
56
120
|
spinner?.stop();
|
|
57
121
|
if (options.json) {
|
|
58
122
|
console.log(JSON.stringify({ ...status, root: rootOutput }, null, 2));
|
package/dist/core/archive.js
CHANGED
|
@@ -46,6 +46,33 @@ export async function isRetirableSpec(specName, rebuilt) {
|
|
|
46
46
|
return (errors.length > 0 &&
|
|
47
47
|
errors.every((issue) => issue.message === VALIDATION_MESSAGES.SPEC_NO_REQUIREMENTS));
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* How much of one blocking line the abort is willing to show. A line long
|
|
51
|
+
* enough to fill the screen would push the way out of the abort off it.
|
|
52
|
+
*/
|
|
53
|
+
const UNACCOUNTED_LINE_MAX = 200;
|
|
54
|
+
/**
|
|
55
|
+
* The first few lines a retirement would delete without being able to name
|
|
56
|
+
* them, quoted, with a count for the rest. Capped so a long tail cannot bury
|
|
57
|
+
* the rest of the abort.
|
|
58
|
+
*
|
|
59
|
+
* The lines are authored spec content printed verbatim to a terminal, so they
|
|
60
|
+
* get the same treatment as a change directory name (`describeChangeName`): a
|
|
61
|
+
* raw CR could forge a line of its own, and an ESC could redraw the screen.
|
|
62
|
+
* Truncation counts code points so a cut never leaves half a surrogate pair.
|
|
63
|
+
*/
|
|
64
|
+
function describeUnaccountedContent(lines) {
|
|
65
|
+
const shown = lines
|
|
66
|
+
.slice(0, 3)
|
|
67
|
+
.map((line) => {
|
|
68
|
+
const safe = [...line.replace(/[\u0000-\u001f\u007f]/g, '?')];
|
|
69
|
+
const clipped = safe.slice(0, UNACCOUNTED_LINE_MAX).join('');
|
|
70
|
+
return `"${safe.length > UNACCOUNTED_LINE_MAX ? `${clipped}\u2026` : clipped}"`;
|
|
71
|
+
})
|
|
72
|
+
.join(', ');
|
|
73
|
+
const rest = lines.length > 3 ? `, and ${lines.length - 3} more line(s)` : '';
|
|
74
|
+
return `${shown}${rest}`;
|
|
75
|
+
}
|
|
49
76
|
async function isRetirementCandidate(update, built, skipValidation) {
|
|
50
77
|
return (!skipValidation &&
|
|
51
78
|
built.noRequirementBlocks &&
|
|
@@ -1252,17 +1279,24 @@ export class ArchiveCommand {
|
|
|
1252
1279
|
const specName = p.update.id;
|
|
1253
1280
|
const report = await new Validator().validateSpecContent(specName, p.rebuilt);
|
|
1254
1281
|
if (!report.valid) {
|
|
1282
|
+
// This run is what emptied the capability, and "no
|
|
1283
|
+
// requirements" is the only thing wrong with the spec that
|
|
1284
|
+
// would be written - so retiring it is what archive would do,
|
|
1285
|
+
// and what stands in the way of that is worth saying. Not
|
|
1286
|
+
// always the *only* fix: a live requirement can be hiding in a
|
|
1287
|
+
// second `## Requirements` section the validator never reaches,
|
|
1288
|
+
// and merging the sections fixes that spec without a deletion.
|
|
1289
|
+
const emptiedByThisRun = p.update.exists &&
|
|
1290
|
+
p.counts.removed > 0 &&
|
|
1291
|
+
p.noRequirementBlocks &&
|
|
1292
|
+
(await isRetirableSpec(specName, p.rebuilt));
|
|
1255
1293
|
// The dead end #1302 describes: the rebuilt spec is unwritable
|
|
1256
1294
|
// for exactly one reason, and retiring the capability is the
|
|
1257
1295
|
// fix - but only the author can authorise deleting the spec, so
|
|
1258
1296
|
// the abort names the marker instead of just rejecting. Says so
|
|
1259
1297
|
// only when the marker is the ONLY thing missing, so it never
|
|
1260
1298
|
// sends someone after a marker that would not have helped.
|
|
1261
|
-
const
|
|
1262
|
-
p.update.exists &&
|
|
1263
|
-
p.counts.removed > 0 &&
|
|
1264
|
-
(await isRetirementCandidate(p.update, p, false));
|
|
1265
|
-
const retirementHint = retirementWouldFix
|
|
1299
|
+
const retirementHint = !retirementDeclared && emptiedByThisRun && p.unaccountedContent.length === 0
|
|
1266
1300
|
? `This change removes the last requirement '${specName}' has. To retire the` +
|
|
1267
1301
|
` capability and delete its spec, add \`retire_capabilities: true\` to the` +
|
|
1268
1302
|
` change's ${METADATA_FILENAME} (alongside its \`schema:\`, which that file` +
|
|
@@ -1271,6 +1305,27 @@ export class ArchiveCommand {
|
|
|
1271
1305
|
? ` The marker present now cannot be honored (${retirementMarker.invalidReason}).`
|
|
1272
1306
|
: '')
|
|
1273
1307
|
: undefined;
|
|
1308
|
+
// #1696: the marker is missing AND the file holds content a
|
|
1309
|
+
// retirement cannot account for, so this abort said nothing at
|
|
1310
|
+
// all - just "must have at least one requirement", with no way
|
|
1311
|
+
// forward. It names the content instead of the marker, on
|
|
1312
|
+
// purpose: the marker is only ever named when adding it would
|
|
1313
|
+
// really let the archive through, and here it would not. Once
|
|
1314
|
+
// the content is resolved the rerun names the marker.
|
|
1315
|
+
const blockedRetirementHint = !retirementDeclared && emptiedByThisRun && p.unaccountedContent.length > 0
|
|
1316
|
+
? `This change removes the last requirement '${specName}' has, so the rebuilt ` +
|
|
1317
|
+
`spec has none left and cannot be written. Retiring the capability is what ` +
|
|
1318
|
+
`archive does instead, and it is refused while the spec holds content the ` +
|
|
1319
|
+
`merge cannot safely account for and deleting the file would take with it: ` +
|
|
1320
|
+
`${describeUnaccountedContent(p.unaccountedContent)}. ` +
|
|
1321
|
+
'Move it into `## Purpose` or a canonical requirement, or delete the spec by hand, then rerun.' +
|
|
1322
|
+
// Said here too, because an author looking at a marker they
|
|
1323
|
+
// believe authorises the deletion should not have to clear
|
|
1324
|
+
// the content first to find out it was never read.
|
|
1325
|
+
(retirementMarker.invalidReason
|
|
1326
|
+
? ` The marker present now cannot be honored (${retirementMarker.invalidReason}).`
|
|
1327
|
+
: '')
|
|
1328
|
+
: undefined;
|
|
1274
1329
|
// The marker was set and retirement was still refused. Saying
|
|
1275
1330
|
// nothing left the author who did exactly what the docs asked
|
|
1276
1331
|
// back in the original dead end with no signal that their
|
|
@@ -1282,13 +1337,13 @@ export class ArchiveCommand {
|
|
|
1282
1337
|
(await isRetirableSpec(specName, p.rebuilt))
|
|
1283
1338
|
? `'${specName}' declares retire_capabilities, but the spec holds content the merge ` +
|
|
1284
1339
|
`cannot safely account for and deleting the file would take with it: ` +
|
|
1285
|
-
`${p.unaccountedContent
|
|
1286
|
-
`${p.unaccountedContent.length > 3 ? `, and ${p.unaccountedContent.length - 3} more line(s)` : ''}. ` +
|
|
1340
|
+
`${describeUnaccountedContent(p.unaccountedContent)}. ` +
|
|
1287
1341
|
'Move it into `## Purpose` or a canonical requirement, or delete the spec by hand.'
|
|
1288
1342
|
: undefined;
|
|
1289
1343
|
if (json) {
|
|
1290
1344
|
throw new ArchiveBlockedError('archive_spec_validation_failed', `Rebuilt spec for '${specName}' failed validation. No files were changed.`, refusalReason ??
|
|
1291
1345
|
retirementHint ??
|
|
1346
|
+
blockedRetirementHint ??
|
|
1292
1347
|
`Run ${withStoreFlag(root, `openspec validate ${specName}`)} after fixing the change deltas.`);
|
|
1293
1348
|
}
|
|
1294
1349
|
console.log(chalk.red(`\nValidation errors in rebuilt spec for ${specName} (will not write changes):`));
|
|
@@ -1300,6 +1355,8 @@ export class ArchiveCommand {
|
|
|
1300
1355
|
}
|
|
1301
1356
|
if (retirementHint)
|
|
1302
1357
|
console.log(chalk.yellow(` → ${retirementHint}`));
|
|
1358
|
+
if (blockedRetirementHint)
|
|
1359
|
+
console.log(chalk.yellow(` → ${blockedRetirementHint}`));
|
|
1303
1360
|
if (refusalReason)
|
|
1304
1361
|
console.log(chalk.yellow(` → ${refusalReason}`));
|
|
1305
1362
|
console.log('Aborted. No files were changed.');
|
|
@@ -3,7 +3,7 @@ import * as path from 'node:path';
|
|
|
3
3
|
import { getSchemaDir, resolveSchema, listSchemasWithInfo } from './resolver.js';
|
|
4
4
|
import { ArtifactGraph } from './graph.js';
|
|
5
5
|
import { detectCompleted } from './state.js';
|
|
6
|
-
import { resolveArtifactOutputPath, resolveArtifactOutputs } from './outputs.js';
|
|
6
|
+
import { isSpecsArtifactPath, resolveArtifactOutputPath, resolveArtifactOutputs, } from './outputs.js';
|
|
7
7
|
import { readChangeMetadata, resolveSchemaForChange } from '../../utils/change-metadata.js';
|
|
8
8
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
9
9
|
import { buildActionContext, buildNextSteps, summarizePlanningHome, } from '../change-status-policy.js';
|
|
@@ -92,12 +92,7 @@ export function loadChangeContext(projectRoot, changeName, schemaName, options =
|
|
|
92
92
|
const skippedArtifacts = new Set();
|
|
93
93
|
if (metadata?.skip_specs) {
|
|
94
94
|
for (const artifact of graph.getAllArtifacts()) {
|
|
95
|
-
|
|
96
|
-
// identically to 'specs/...', so the skip set must too, or validate
|
|
97
|
-
// would honor the marker while instructions tell the agent to create
|
|
98
|
-
// the very files the conflict gate polices.
|
|
99
|
-
const generates = artifact.generates.replace(/^(?:\.\/)+/, '');
|
|
100
|
-
if (generates.startsWith('specs/') && !completed.has(artifact.id)) {
|
|
95
|
+
if (isSpecsArtifactPath(artifact.generates) && !completed.has(artifact.id)) {
|
|
101
96
|
completed.add(artifact.id);
|
|
102
97
|
skippedArtifacts.add(artifact.id);
|
|
103
98
|
}
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
* Checks if a path contains glob pattern characters.
|
|
3
3
|
*/
|
|
4
4
|
export declare function isGlobPattern(pattern: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Returns whether an artifact generates files under the change's specs/ tree.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isSpecsArtifactPath(generates: string): boolean;
|
|
5
9
|
export declare function resolveArtifactOutputPath(changeDir: string, generates: string): string;
|
|
6
10
|
/**
|
|
7
11
|
* Resolves an artifact's output path(s) to concrete files that currently exist.
|
|
@@ -8,6 +8,13 @@ import { FileSystemUtils } from '../../utils/file-system.js';
|
|
|
8
8
|
export function isGlobPattern(pattern) {
|
|
9
9
|
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Returns whether an artifact generates files under the change's specs/ tree.
|
|
13
|
+
*/
|
|
14
|
+
export function isSpecsArtifactPath(generates) {
|
|
15
|
+
const normalized = path.posix.normalize(FileSystemUtils.toPosixPath(generates));
|
|
16
|
+
return normalized.startsWith('specs/');
|
|
17
|
+
}
|
|
11
18
|
export function resolveArtifactOutputPath(changeDir, generates) {
|
|
12
19
|
const outputPath = path.join(changeDir, generates);
|
|
13
20
|
FileSystemUtils.assertPathWithin(changeDir, outputPath);
|
|
@@ -53,18 +53,24 @@ export function getProjectSchemasDir(projectRoot) {
|
|
|
53
53
|
* @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })`
|
|
54
54
|
*/
|
|
55
55
|
/**
|
|
56
|
-
* Directories `schema fork`
|
|
57
|
-
* place: a staging copy (`.fork-staging-<rand
|
|
58
|
-
* backup of the previous
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
56
|
+
* Directories `schema fork` and `schema init` create transiently while swapping
|
|
57
|
+
* a schema into place: a staging copy (`.fork-staging-<rand>` /
|
|
58
|
+
* `.init-staging-<rand>`, created via mkdtemp) and a backup of the previous
|
|
59
|
+
* destination (`<name>.fork-backup-<pid>-<ts>` /
|
|
60
|
+
* `<name>.init-backup-<pid>-<ts>`). Either can briefly coexist with real
|
|
61
|
+
* schemas in the schemas dir, and a backup outlives the run when its cleanup is
|
|
62
|
+
* blocked, so discovery must never surface them. Real schema names are
|
|
63
|
+
* kebab-case (no dots), so excluding these dot-bearing temp names can never
|
|
64
|
+
* hide a legitimate schema.
|
|
62
65
|
*/
|
|
63
|
-
function
|
|
64
|
-
return name.startsWith('.fork-staging-') ||
|
|
66
|
+
function isOwnedTransientSchemaDir(name) {
|
|
67
|
+
return (name.startsWith('.fork-staging-') ||
|
|
68
|
+
name.includes('.fork-backup-') ||
|
|
69
|
+
name.startsWith('.init-staging-') ||
|
|
70
|
+
name.includes('.init-backup-'));
|
|
65
71
|
}
|
|
66
72
|
export function isSchemaDir(parentDir, entry) {
|
|
67
|
-
if (
|
|
73
|
+
if (isOwnedTransientSchemaDir(entry.name)) {
|
|
68
74
|
return false;
|
|
69
75
|
}
|
|
70
76
|
if (entry.isDirectory()) {
|