@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.
Files changed (72) hide show
  1. package/dist/cli/index.d.ts +17 -0
  2. package/dist/cli/index.js +60 -9
  3. package/dist/commands/change.d.ts +24 -0
  4. package/dist/commands/change.js +223 -0
  5. package/dist/commands/config.js +14 -3
  6. package/dist/commands/feedback.js +28 -7
  7. package/dist/commands/schema.d.ts +5 -0
  8. package/dist/commands/schema.js +201 -35
  9. package/dist/commands/show.js +1 -1
  10. package/dist/commands/workflow/index.d.ts +1 -1
  11. package/dist/commands/workflow/index.js +1 -1
  12. package/dist/commands/workflow/status.d.ts +2 -0
  13. package/dist/commands/workflow/status.js +76 -12
  14. package/dist/core/archive.js +64 -7
  15. package/dist/core/artifact-graph/instruction-loader.js +2 -7
  16. package/dist/core/artifact-graph/outputs.d.ts +4 -0
  17. package/dist/core/artifact-graph/outputs.js +7 -0
  18. package/dist/core/artifact-graph/resolver.js +15 -9
  19. package/dist/core/available-tools.js +16 -1
  20. package/dist/core/command-generation/adapters/antigravity.d.ts +1 -1
  21. package/dist/core/command-generation/adapters/antigravity.js +2 -2
  22. package/dist/core/command-generation/adapters/opencode.d.ts +2 -1
  23. package/dist/core/command-generation/adapters/opencode.js +13 -2
  24. package/dist/core/completion-tip.d.ts +15 -0
  25. package/dist/core/completion-tip.js +133 -0
  26. package/dist/core/completions/command-registry.js +20 -0
  27. package/dist/core/completions/factory.d.ts +11 -0
  28. package/dist/core/completions/generators/fish-generator.d.ts +12 -0
  29. package/dist/core/completions/generators/fish-generator.js +79 -47
  30. package/dist/core/completions/installers/bash-installer.d.ts +7 -0
  31. package/dist/core/completions/installers/bash-installer.js +15 -0
  32. package/dist/core/completions/installers/fish-installer.d.ts +7 -0
  33. package/dist/core/completions/installers/fish-installer.js +15 -0
  34. package/dist/core/completions/installers/powershell-installer.d.ts +7 -0
  35. package/dist/core/completions/installers/powershell-installer.js +15 -0
  36. package/dist/core/completions/templates/fish-templates.d.ts +1 -1
  37. package/dist/core/completions/templates/fish-templates.js +107 -10
  38. package/dist/core/completions/types.d.ts +4 -0
  39. package/dist/core/config-prompts.js +19 -10
  40. package/dist/core/config-schema.d.ts +1 -0
  41. package/dist/core/config-schema.js +2 -0
  42. package/dist/core/config.js +7 -1
  43. package/dist/core/global-config.d.ts +2 -0
  44. package/dist/core/init.d.ts +5 -0
  45. package/dist/core/init.js +117 -14
  46. package/dist/core/legacy-cleanup.js +6 -0
  47. package/dist/core/migration.js +42 -12
  48. package/dist/core/parsers/requirement-blocks.d.ts +1 -0
  49. package/dist/core/parsers/requirement-blocks.js +2 -0
  50. package/dist/core/profiles.d.ts +1 -1
  51. package/dist/core/profiles.js +11 -2
  52. package/dist/core/shared/tool-detection.js +9 -3
  53. package/dist/core/shared-skill-target.d.ts +13 -0
  54. package/dist/core/shared-skill-target.js +43 -1
  55. package/dist/core/specs-apply.js +16 -8
  56. package/dist/core/templates/workflows/explore.js +79 -73
  57. package/dist/core/templates/workflows/feedback.js +1 -0
  58. package/dist/core/templates/workflows/onboard.js +4 -4
  59. package/dist/core/update.js +39 -26
  60. package/dist/core/validation/constants.d.ts +3 -0
  61. package/dist/core/validation/constants.js +12 -0
  62. package/dist/core/validation/purpose-placeholder.d.ts +45 -0
  63. package/dist/core/validation/purpose-placeholder.js +117 -0
  64. package/dist/core/validation/validator.js +16 -1
  65. package/dist/telemetry/index.js +3 -2
  66. package/dist/utils/change-metadata.js +17 -13
  67. package/dist/utils/change-utils.js +5 -0
  68. package/dist/utils/requirement-diff.d.ts +42 -0
  69. package/dist/utils/requirement-diff.js +61 -0
  70. package/package.json +4 -6
  71. package/schemas/spec-driven/schema.yaml +17 -7
  72. package/scripts/postinstall.js +0 -83
@@ -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
- // Replace only after all inputs have been collected and validated
956
- if (schemaExists) {
957
- if (spinner)
958
- spinner.start(`Removing existing schema '${name}'...`);
959
- fs.rmSync(schemaDir, { recursive: true });
960
- }
961
- // Create schema directory
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(schemaDir, { recursive: true });
965
- fs.writeFileSync(path.join(schemaDir, 'schema.yaml'), stringifyYaml(schema));
966
- // Create template files in templates/ subdirectory (standard location)
967
- const templatesDir = path.join(schemaDir, 'templates');
968
- for (const artifact of selectedArtifacts) {
969
- const templatePath = path.join(templatesDir, artifact.template);
970
- const templateDir = path.dirname(templatePath);
971
- if (!fs.existsSync(templateDir)) {
972
- fs.mkdirSync(templateDir, { recursive: true });
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
- // Update config if --default
979
- if (options?.default) {
980
- const configPath = path.join(projectRoot, 'openspec', 'config.yaml');
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
- else {
989
- // Create config file
990
- const configDir = path.dirname(configPath);
991
- if (!fs.existsSync(configDir)) {
992
- fs.mkdirSync(configDir, { recursive: true });
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)
@@ -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
- // Command Implementation
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
- const root = await resolveRootForCommand(options, { json: options.json });
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
- // Changes exist but --change not provided
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 context = loadChangeContext(projectRoot, changeName, options.schema, {
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));
@@ -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 retirementWouldFix = !retirementDeclared &&
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.slice(0, 3).map((line) => `"${line}"`).join(', ')}` +
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
- // A schema may write generates as './specs/...' - the globs treat that
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` creates transiently while swapping a fork into
57
- * place: a staging copy (`.fork-staging-<rand>`, created via mkdtemp) and a
58
- * backup of the previous destination (`<name>.fork-backup-<pid>-<ts>`). Either
59
- * can briefly coexist with real schemas in the schemas dir, so discovery must
60
- * never surface them. Real schema names are kebab-case (no dots), so excluding
61
- * these dot-bearing temp names can never hide a legitimate schema.
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 isOwnedForkTempDir(name) {
64
- return name.startsWith('.fork-staging-') || name.includes('.fork-backup-');
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 (isOwnedForkTempDir(entry.name)) {
73
+ if (isOwnedTransientSchemaDir(entry.name)) {
68
74
  return false;
69
75
  }
70
76
  if (entry.isDirectory()) {