@fission-ai/openspec 1.13.0 → 1.13.2

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 (113) hide show
  1. package/README.md +16 -4
  2. package/dist/commands/change.js +12 -1
  3. package/dist/commands/config.d.ts +9 -0
  4. package/dist/commands/config.js +161 -22
  5. package/dist/commands/feedback.js +6 -4
  6. package/dist/commands/schema.js +30 -9
  7. package/dist/commands/store.js +5 -2
  8. package/dist/commands/validate.d.ts +7 -0
  9. package/dist/commands/validate.js +63 -1
  10. package/dist/commands/workflow/instructions.js +69 -22
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.d.ts +5 -0
  13. package/dist/commands/workflow/shared.js +8 -0
  14. package/dist/commands/workflow/status.d.ts +5 -1
  15. package/dist/commands/workflow/status.js +42 -5
  16. package/dist/core/archive.js +260 -77
  17. package/dist/core/artifact-graph/outputs.d.ts +1 -1
  18. package/dist/core/artifact-graph/outputs.js +38 -8
  19. package/dist/core/artifact-graph/schema.d.ts +14 -0
  20. package/dist/core/artifact-graph/schema.js +52 -0
  21. package/dist/core/artifact-graph/types.js +12 -1
  22. package/dist/core/change-status-policy.d.ts +14 -0
  23. package/dist/core/change-status-policy.js +14 -6
  24. package/dist/core/command-generation/adapters/continue.js +4 -0
  25. package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
  26. package/dist/core/command-generation/adapters/kilocode.js +2 -2
  27. package/dist/core/command-generation/generator.js +2 -0
  28. package/dist/core/completion-tip.js +7 -7
  29. package/dist/core/completions/installers/bash-installer.js +15 -7
  30. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  31. package/dist/core/completions/installers/shell-quote.js +13 -0
  32. package/dist/core/completions/installers/zsh-installer.js +3 -2
  33. package/dist/core/config-schema.d.ts +1 -1
  34. package/dist/core/config.d.ts +16 -0
  35. package/dist/core/config.js +27 -1
  36. package/dist/core/global-config.d.ts +30 -2
  37. package/dist/core/global-config.js +57 -3
  38. package/dist/core/init.js +22 -6
  39. package/dist/core/legacy-cleanup.d.ts +5 -1
  40. package/dist/core/legacy-cleanup.js +207 -14
  41. package/dist/core/list.js +55 -11
  42. package/dist/core/migration.js +6 -1
  43. package/dist/core/parsers/change-parser.d.ts +12 -1
  44. package/dist/core/parsers/change-parser.js +85 -87
  45. package/dist/core/parsers/markdown-parser.js +4 -3
  46. package/dist/core/parsers/requirement-blocks.d.ts +78 -6
  47. package/dist/core/parsers/requirement-blocks.js +155 -38
  48. package/dist/core/parsers/requirement-text.d.ts +13 -2
  49. package/dist/core/parsers/requirement-text.js +34 -7
  50. package/dist/core/parsers/spec-structure.js +4 -1
  51. package/dist/core/project-config.d.ts +9 -1
  52. package/dist/core/project-config.js +11 -2
  53. package/dist/core/references.d.ts +20 -0
  54. package/dist/core/references.js +56 -0
  55. package/dist/core/shared/skill-content-equivalence.js +5 -1
  56. package/dist/core/shared/skill-generation.js +36 -8
  57. package/dist/core/shared/tool-detection.js +9 -3
  58. package/dist/core/specs-apply.d.ts +20 -0
  59. package/dist/core/specs-apply.js +101 -17
  60. package/dist/core/store/git.d.ts +33 -0
  61. package/dist/core/store/git.js +60 -6
  62. package/dist/core/store/operations.d.ts +1 -1
  63. package/dist/core/store/operations.js +32 -10
  64. package/dist/core/store/registry.d.ts +2 -1
  65. package/dist/core/store/registry.js +5 -1
  66. package/dist/core/templates/optional-workflow.d.ts +69 -0
  67. package/dist/core/templates/optional-workflow.js +140 -0
  68. package/dist/core/templates/workflows/apply-change.js +18 -3
  69. package/dist/core/templates/workflows/archive-change.js +70 -21
  70. package/dist/core/templates/workflows/bulk-archive-change.js +85 -21
  71. package/dist/core/templates/workflows/continue-change.js +14 -4
  72. package/dist/core/templates/workflows/explore.js +34 -16
  73. package/dist/core/templates/workflows/ff-change.js +21 -5
  74. package/dist/core/templates/workflows/new-change.js +15 -3
  75. package/dist/core/templates/workflows/onboard.js +76 -41
  76. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  77. package/dist/core/templates/workflows/project-root.js +38 -0
  78. package/dist/core/templates/workflows/propose.js +22 -5
  79. package/dist/core/templates/workflows/sync-specs.js +40 -1
  80. package/dist/core/templates/workflows/update-change.js +66 -27
  81. package/dist/core/templates/workflows/verify-change.js +121 -49
  82. package/dist/core/update.d.ts +12 -0
  83. package/dist/core/update.js +78 -10
  84. package/dist/core/validation/purpose-placeholder.js +24 -9
  85. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  86. package/dist/core/validation/task-checkboxes.js +181 -0
  87. package/dist/core/validation/validator.d.ts +53 -1
  88. package/dist/core/validation/validator.js +160 -45
  89. package/dist/core/version-check.d.ts +14 -7
  90. package/dist/core/version-check.js +63 -17
  91. package/dist/core/worksets.js +6 -4
  92. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  93. package/dist/prompts/searchable-multi-select.js +35 -6
  94. package/dist/telemetry/config.js +14 -2
  95. package/dist/telemetry/index.d.ts +4 -3
  96. package/dist/telemetry/index.js +22 -10
  97. package/dist/telemetry/opt-out.d.ts +17 -0
  98. package/dist/telemetry/opt-out.js +27 -0
  99. package/dist/utils/file-system.js +20 -3
  100. package/dist/utils/line-endings.d.ts +34 -0
  101. package/dist/utils/line-endings.js +46 -0
  102. package/dist/utils/nested-change.d.ts +35 -0
  103. package/dist/utils/nested-change.js +186 -0
  104. package/dist/utils/spec-discovery.d.ts +20 -0
  105. package/dist/utils/spec-discovery.js +59 -0
  106. package/dist/utils/task-progress.d.ts +1 -1
  107. package/dist/utils/task-progress.js +38 -6
  108. package/package.json +5 -5
  109. package/schemas/spec-driven/schema.yaml +18 -3
  110. package/schemas/spec-driven/templates/design.md +2 -0
  111. package/schemas/spec-driven/templates/proposal.md +2 -0
  112. package/schemas/spec-driven/templates/spec.md +2 -0
  113. package/schemas/spec-driven/templates/tasks.md +2 -0
@@ -7,11 +7,12 @@
7
7
  import ora from 'ora';
8
8
  import path from 'path';
9
9
  import * as fs from 'fs';
10
- import { loadChangeContext, generateInstructions, resolveSchema, resolveArtifactOutputPath, resolveArtifactOutputs, } from '../../core/artifact-graph/index.js';
10
+ import { loadChangeContext, generateInstructions, resolveSchema, resolveArtifactOutputs, } from '../../core/artifact-graph/index.js';
11
11
  import { isSpecsArtifactPath } from '../../core/artifact-graph/outputs.js';
12
+ import { findUnreadDeltaFiles } from '../../utils/spec-discovery.js';
12
13
  import { getChangeDir, resolveCurrentPlanningHomeSync, } from '../../core/planning-home.js';
13
14
  import { resolveRootForCommand, withStoreFlag, toPlanningHome, toRootOutput, } from '../../core/root-selection.js';
14
- import { assembleReferenceIndex, renderReferencedStoresBlock, renderReferencedStoresSection, } from '../../core/references.js';
15
+ import { assembleReferenceIndex, escapeEnvelopeAttribute, escapeEnvelopeTags, renderReferencedStoresBlock, renderReferencedStoresSection, sanitizeInline, } from '../../core/references.js';
15
16
  import { readRegistrySnapshot } from '../../core/store/registry.js';
16
17
  import { loadOperationInputs, readProjectConfig, } from '../../core/project-config.js';
17
18
  import { validateChangeExists, validateSchemaExists, } from './shared.js';
@@ -95,8 +96,12 @@ export async function instructionsCommand(artifactId, options) {
95
96
  }
96
97
  export function printInstructionsText(instructions, isBlocked) {
97
98
  const { artifactId, changeName, schemaName, changeDir, resolvedOutputPath, description, instruction, context, rules, template, dependencies, unlocks, } = instructions;
98
- // Opening tag
99
- console.log(`<artifact id="${artifactId}" change="${changeName}" schema="${schemaName}">`);
99
+ // Opening tag. The change name is a directory name read from disk, and the
100
+ // read path rejects only separators and NUL - a quote in it would otherwise
101
+ // close the attribute and forge siblings on this tag.
102
+ console.log(`<artifact id="${escapeEnvelopeAttribute(artifactId)}"` +
103
+ ` change="${escapeEnvelopeAttribute(changeName)}"` +
104
+ ` schema="${escapeEnvelopeAttribute(schemaName)}">`);
100
105
  console.log();
101
106
  // Artifacts skipped via skip_specs get no creation directive: emitting the
102
107
  // task/template anyway would prompt an agent to write spec files that
@@ -120,15 +125,15 @@ export function printInstructionsText(instructions, isBlocked) {
120
125
  }
121
126
  // Task directive
122
127
  console.log('<task>');
123
- console.log(`Create the ${artifactId} artifact for change "${changeName}".`);
124
- console.log(description);
128
+ console.log(`Create the ${escapeEnvelopeTags(artifactId)} artifact for change "${escapeEnvelopeTags(changeName)}".`);
129
+ console.log(escapeEnvelopeTags(description));
125
130
  console.log('</task>');
126
131
  console.log();
127
132
  // Project context (AI constraint - do not include in output)
128
133
  if (context) {
129
134
  console.log('<project_context>');
130
135
  console.log('<!-- This is background information for you. Do NOT include this in your output. -->');
131
- console.log(context);
136
+ console.log(escapeEnvelopeTags(context));
132
137
  console.log('</project_context>');
133
138
  console.log();
134
139
  }
@@ -142,7 +147,9 @@ export function printInstructionsText(instructions, isBlocked) {
142
147
  console.log('<rules>');
143
148
  console.log('<!-- These are constraints for you to follow. Do NOT include this in your output. -->');
144
149
  for (const rule of rules) {
145
- console.log(`- ${rule}`);
150
+ // Flattened so a newline cannot forge a sibling bullet, but never
151
+ // truncated: these are instructions an agent has to follow in full.
152
+ console.log(`- ${escapeEnvelopeTags(sanitizeInline(rule, Infinity))}`);
146
153
  }
147
154
  console.log('</rules>');
148
155
  console.log();
@@ -166,7 +173,7 @@ export function printInstructionsText(instructions, isBlocked) {
166
173
  const fullPath = path.join(changeDir, dep.path);
167
174
  console.log(`<dependency id="${dep.id}" status="${status}">`);
168
175
  console.log(` <path>${fullPath}</path>`);
169
- console.log(` <description>${dep.description}</description>`);
176
+ console.log(` <description>${escapeEnvelopeTags(dep.description)}</description>`);
170
177
  console.log('</dependency>');
171
178
  }
172
179
  console.log('</dependencies>');
@@ -180,14 +187,17 @@ export function printInstructionsText(instructions, isBlocked) {
180
187
  // Instruction (guidance)
181
188
  if (instruction) {
182
189
  console.log('<instruction>');
183
- console.log(instruction.trim());
190
+ console.log(escapeEnvelopeTags(instruction.trim()));
184
191
  console.log('</instruction>');
185
192
  console.log();
186
193
  }
187
194
  // Template
188
195
  console.log('<template>');
189
196
  console.log('<!-- Use this as the structure for your output file. Fill in the sections. -->');
190
- console.log(template.trim());
197
+ // Copied verbatim into the artifact file, so its `<!-- ... -->` comments and
198
+ // `<placeholder>` markers must survive - only the envelope's own closing
199
+ // tags are neutralized.
200
+ console.log(escapeEnvelopeTags(template.trim()));
191
201
  console.log('</template>');
192
202
  console.log();
193
203
  // Success criteria placeholder
@@ -298,8 +308,12 @@ function collectMissingPrerequisites(input) {
298
308
  * reached tasks yet, the missing specs are the next step rather than a warning.
299
309
  * Schemas that declare no spec-producing artifact carry `skip_specs` from
300
310
  * creation, so this never fires on them.
311
+ *
312
+ * A delta file the merge path never reads (specs/<capability>.md, a note
313
+ * beside spec.md) still satisfies the specs glob, so it reads as written here
314
+ * while validate rejects it and archive would drop it. Each one is named.
301
315
  */
302
- function collectApplyWarnings(input) {
316
+ async function collectApplyWarnings(input) {
303
317
  const { state, schema, changeDir, changeName, skippedArtifacts } = input;
304
318
  if (state === 'blocked')
305
319
  return [];
@@ -308,9 +322,11 @@ function collectApplyWarnings(input) {
308
322
  return [];
309
323
  if (specArtifacts.some((artifact) => skippedArtifacts?.has(artifact.id)))
310
324
  return [];
325
+ const warnings = (await findUnreadDeltaFiles(path.join(changeDir, 'specs'))).map((file) => `specs/${file.path} is not a capability's spec.md, so \`openspec validate ${changeName}\` rejects it and archive never merges it. ` +
326
+ `Move its requirements into specs/${file.expected}.`);
311
327
  const hasDeltas = specArtifacts.some((artifact) => resolveArtifactOutputs(changeDir, artifact.generates).length > 0);
312
328
  if (hasDeltas)
313
- return [];
329
+ return warnings;
314
330
  const metadataPath = path.join(changeDir, METADATA_FILENAME);
315
331
  // The command names the artifact this schema actually declares, never the
316
332
  // literal `specs`. A schema whose spec-producing artifact is `contracts` was
@@ -320,6 +336,7 @@ function collectApplyWarnings(input) {
320
336
  // a placeholder rather than a guess.
321
337
  const specTarget = specArtifacts.length === 1 ? specArtifacts[0].id : '<artifact-id>';
322
338
  return [
339
+ ...warnings,
323
340
  `This change has no delta specs and does not declare \`skip_specs: true\`, so \`openspec validate ${changeName}\` fails on it. ` +
324
341
  `Write the delta specs before implementing (\`openspec instructions ${specTarget} --change ${changeName}\`), ` +
325
342
  `or add \`skip_specs: true\` to ${metadataPath} if this change really changes no specified behavior.`,
@@ -377,15 +394,28 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
377
394
  contextFiles[artifact.id] = outputs;
378
395
  }
379
396
  }
380
- // Parse tasks if tracking file exists
397
+ // Parse every concrete file matched by apply.tracks. A tracking path may be
398
+ // a glob owned by an artifact with any ID, so treating it as one literal
399
+ // path loses task evidence for valid custom schemas.
381
400
  let parsedTasks = [];
401
+ const unavailableTrackingFiles = [];
382
402
  let tracksFileExists = false;
383
403
  if (tracksFile) {
384
- const tracksPath = resolveArtifactOutputPath(changeDir, tracksFile);
385
- tracksFileExists = fs.existsSync(tracksPath);
386
- if (tracksFileExists) {
387
- const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8');
388
- parsedTasks = parseTaskLines(tasksContent);
404
+ const tracksPaths = resolveArtifactOutputs(changeDir, tracksFile);
405
+ tracksFileExists = tracksPaths.length > 0;
406
+ for (const tracksPath of tracksPaths) {
407
+ try {
408
+ const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8');
409
+ parsedTasks.push(...parseTaskLines(tasksContent));
410
+ }
411
+ catch (error) {
412
+ const code = error?.code;
413
+ const message = error instanceof Error ? error.message : String(error);
414
+ unavailableTrackingFiles.push({
415
+ path: tracksPath,
416
+ reason: code && !message.includes(code) ? `${code}: ${message}` : message,
417
+ });
418
+ }
389
419
  }
390
420
  }
391
421
  const tasks = toTaskItems(parsedTasks);
@@ -418,6 +448,10 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
418
448
  `The ${tracksFilename} file is missing and must be created.` +
419
449
  `\n${describeArtifactRemedy(changeName, findArtifactIdFor(schema, tracksFile))}`;
420
450
  }
451
+ else if (tracksFile && unavailableTrackingFiles.length > 0 && tasks.length === 0) {
452
+ state = 'blocked';
453
+ instruction = 'No readable task descriptions are available.';
454
+ }
421
455
  else if (tracksFile && tracksFileExists && tasks.length === 0) {
422
456
  // Tracking file exists but lists nothing an agent can work on: either no
423
457
  // checkboxes at all, or only checkboxes with no text after them.
@@ -427,7 +461,10 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
427
461
  `The ${tracksFilename} file exists but contains no tasks to work on.` +
428
462
  `\nAdd tasks to ${tracksFilename}, or rebuild it: ${describeArtifactRemedy(changeName, findArtifactIdFor(schema, tracksFile))}`;
429
463
  }
430
- else if (tracksFile && remaining === 0 && total > 0) {
464
+ else if (tracksFile &&
465
+ unavailableTrackingFiles.length === 0 &&
466
+ remaining === 0 &&
467
+ total > 0) {
431
468
  state = 'all_done';
432
469
  instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.';
433
470
  }
@@ -440,7 +477,13 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
440
477
  state = 'ready';
441
478
  instruction = schemaInstruction?.trim() ?? 'Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.';
442
479
  }
443
- const warnings = collectApplyWarnings({
480
+ if (unavailableTrackingFiles.length > 0) {
481
+ const unavailableDetails = unavailableTrackingFiles
482
+ .map((file) => `- ${file.path}: ${file.reason}`)
483
+ .join('\n');
484
+ instruction += `\nTask completion is not verified because tracking evidence was unavailable:\n${unavailableDetails}`;
485
+ }
486
+ const warnings = await collectApplyWarnings({
444
487
  state,
445
488
  schema,
446
489
  changeDir,
@@ -454,6 +497,8 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
454
497
  contextFiles,
455
498
  progress: { total, complete, remaining },
456
499
  tasks,
500
+ taskTrackingConfigured: tracksFile !== null,
501
+ ...(unavailableTrackingFiles.length > 0 ? { unavailableTrackingFiles } : {}),
457
502
  state,
458
503
  missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : undefined,
459
504
  ...(missingPrerequisites.length > 0 ? { missingPrerequisites } : {}),
@@ -599,13 +644,15 @@ export function printArchiveInstructionsText(instructions) {
599
644
  function printOperationInputsText(inputs) {
600
645
  if (inputs.context) {
601
646
  console.log('### Project Context (required instruction input)');
647
+ // Printed verbatim on purpose. Escaping a leading `#` would also fire inside
648
+ // fenced code (`# install deps`), so heading forgery is not guarded here.
602
649
  console.log(inputs.context);
603
650
  console.log();
604
651
  }
605
652
  if (inputs.operationGuidance && inputs.operationGuidance.length > 0) {
606
653
  console.log('### Operation Guidance (advisory)');
607
654
  for (const guidance of inputs.operationGuidance) {
608
- console.log(`- ${guidance}`);
655
+ console.log(`- ${sanitizeInline(guidance, Infinity)}`);
609
656
  }
610
657
  console.log();
611
658
  }
@@ -6,6 +6,7 @@
6
6
  * root; initiative linking and workspace affected areas are no longer part of
7
7
  * this command.
8
8
  */
9
+ import chalk from 'chalk';
9
10
  import ora from 'ora';
10
11
  import path from 'path';
11
12
  import { createChange, validateChangeName } from '../../utils/change-utils.js';
@@ -33,6 +34,24 @@ function printCreatedChangeHuman(payload, root) {
33
34
  console.log(`Schema: ${payload.change.schema}`);
34
35
  console.log(`Next: ${withStoreFlag(root, `openspec status --change ${payload.change.id}`)}`);
35
36
  }
37
+ /**
38
+ * An implicit root is the fallback taken when no `openspec/` directory was
39
+ * found: creating a change there materializes OpenSpec in whatever directory
40
+ * the caller happened to be in, which is how an agent ends up adopting a
41
+ * project that never ran `openspec init` (#1645). The creation itself stays
42
+ * zero-config; this only makes it visible.
43
+ */
44
+ function printImplicitRootNotice(root) {
45
+ if (root.source !== 'implicit') {
46
+ return;
47
+ }
48
+ const openspecDir = path.dirname(root.changesDir);
49
+ const relative = path.relative(process.cwd(), openspecDir);
50
+ const location = relative && !relative.startsWith('..') ? relative : openspecDir;
51
+ console.log();
52
+ console.log(chalk.dim(`Note: no OpenSpec root was found here, so one was created at ${location}/.`));
53
+ console.log(chalk.dim('Run `openspec init` to finish setting this project up, or delete that directory if you meant a different project.'));
54
+ }
36
55
  export async function newChangeCommand(name, options) {
37
56
  const spinner = options.json ? undefined : ora();
38
57
  try {
@@ -89,6 +108,7 @@ export async function newChangeCommand(name, options) {
89
108
  }
90
109
  spinner?.stop();
91
110
  printCreatedChangeHuman(payload, root);
111
+ printImplicitRootNotice(root);
92
112
  }
93
113
  catch (error) {
94
114
  spinner?.stop();
@@ -28,6 +28,11 @@ export interface ApplyInstructions {
28
28
  remaining: number;
29
29
  };
30
30
  tasks: TaskItem[];
31
+ taskTrackingConfigured: boolean;
32
+ unavailableTrackingFiles?: Array<{
33
+ path: string;
34
+ reason: string;
35
+ }>;
31
36
  state: 'blocked' | 'all_done' | 'ready';
32
37
  missingArtifacts?: string[];
33
38
  /**
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import chalk from 'chalk';
8
8
  import path from 'path';
9
+ import { describeNestedChange, findNestedChangesIn, } from '../../utils/nested-change.js';
9
10
  import * as fs from 'fs';
10
11
  import { getSchemaDir, listSchemas } from '../../core/artifact-graph/index.js';
11
12
  import { isRootSelectionError } from '../../core/root-selection.js';
@@ -144,6 +145,13 @@ export async function validateChangeExists(changeName, projectRoot, changesDir =
144
145
  }
145
146
  throw new Error(`Change '${changeName}' not found. Available changes:\n ${available.join('\n ')}`);
146
147
  }
148
+ // The directory exists but is a namespace folder wrapping nested change
149
+ // directories. Every artifact lookup below it would report "not started" for
150
+ // work that is in fact there, so say what is actually wrong instead (#1846).
151
+ const nested = await findNestedChangesIn(changesDir, changeName);
152
+ if (nested) {
153
+ throw new Error(describeNestedChange(nested));
154
+ }
147
155
  return changeName;
148
156
  }
149
157
  /**
@@ -14,5 +14,9 @@ export interface StatusOptions {
14
14
  }
15
15
  export declare const BATCH_STATUS_FAILURE_PAYLOAD: Record<string, unknown>;
16
16
  export declare function statusCommand(options: StatusOptions): Promise<void>;
17
- export declare function printStatusText(status: ChangeStatus): void;
17
+ export interface PrintStatusTextOptions {
18
+ /** Selected store id, so the printed command carries `--store`. */
19
+ storeId?: string;
20
+ }
21
+ export declare function printStatusText(status: ChangeStatus, options?: PrintStatusTextOptions): void;
18
22
  //# sourceMappingURL=status.d.ts.map
@@ -8,7 +8,9 @@ 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 { resolveNextStep } from '../../core/change-status-policy.js';
11
12
  import { asStatus } from '../shared-output.js';
13
+ import { describeNestedChange, findNestedChanges, } from '../../utils/nested-change.js';
12
14
  import { validateChangeExists, validateSchemaExists, getAvailableChanges, getStatusIndicator, getStatusColor, } from './shared.js';
13
15
  // The --all --json failure null-shape. Root-selection failures (handled in
14
16
  // resolveRootForCommand) and thrown errors (caught by the CLI wrapper) must
@@ -37,12 +39,16 @@ export async function statusCommand(options) {
37
39
  const projectRoot = root.path;
38
40
  const rootOutput = toRootOutput(root);
39
41
  const newChangeHint = withStoreFlag(root, 'openspec new change <name>');
42
+ // One store-flag decision serves the JSON `nextSteps` sentence and the text
43
+ // `Next:` line, so a store-selected root can never carry `--store` in one
44
+ // and drop it from the other.
45
+ const storeOptions = isStoreSelectedRoot(root) ? { storeId: root.storeId } : {};
40
46
  // Single definition of "load one change's status" so the batch and
41
47
  // single-change payloads can never drift apart.
42
48
  const loadStatus = (changeName) => formatChangeStatus(loadChangeContext(projectRoot, changeName, options.schema, {
43
49
  changeDir: getChangeDir(planningHome, changeName),
44
50
  planningHome,
45
- }), isStoreSelectedRoot(root) ? { storeId: root.storeId } : {});
51
+ }), storeOptions);
46
52
  // Handle no-changes case gracefully — status is informational,
47
53
  // so "no changes" is a valid state, not an error.
48
54
  if (!options.change) {
@@ -66,7 +72,23 @@ export async function statusCommand(options) {
66
72
  // with the same comparator validate --all uses so the two batch
67
73
  // commands order a given change set identically.
68
74
  const entries = [];
75
+ // The sweep reads each directory straight through `loadStatus`, so a
76
+ // namespace folder wrapping nested changes would report a whole
77
+ // artifact plan for work that is not there (#1846). It carries the same
78
+ // per-change diagnostic a malformed change does.
79
+ const nestedByName = new Map((await findNestedChanges(root.changesDir, available)).map((finding) => [
80
+ finding.name,
81
+ finding,
82
+ ]));
69
83
  for (const changeName of available.sort((a, b) => a.localeCompare(b))) {
84
+ const nested = nestedByName.get(changeName);
85
+ if (nested) {
86
+ entries.push({
87
+ changeName,
88
+ status: [asStatus(new Error(describeNestedChange(nested)), 'change_error')],
89
+ });
90
+ continue;
91
+ }
70
92
  try {
71
93
  entries.push(loadStatus(changeName));
72
94
  }
@@ -90,7 +112,7 @@ export async function statusCommand(options) {
90
112
  console.log();
91
113
  }
92
114
  if ('artifacts' in entry) {
93
- printStatusText(entry);
115
+ printStatusText(entry, storeOptions);
94
116
  }
95
117
  else {
96
118
  console.log(chalk.red(`✗ ${entry.changeName}: ${entry.status[0]?.message}`));
@@ -122,14 +144,14 @@ export async function statusCommand(options) {
122
144
  console.log(JSON.stringify({ ...status, root: rootOutput }, null, 2));
123
145
  return;
124
146
  }
125
- printStatusText(status);
147
+ printStatusText(status, storeOptions);
126
148
  }
127
149
  catch (error) {
128
150
  spinner?.stop();
129
151
  throw error;
130
152
  }
131
153
  }
132
- export function printStatusText(status) {
154
+ export function printStatusText(status, options = {}) {
133
155
  const doneCount = status.artifacts.filter((a) => a.status === 'done').length;
134
156
  const skippedCount = status.artifacts.filter((a) => a.status === 'skipped').length;
135
157
  const total = status.artifacts.length - skippedCount;
@@ -153,9 +175,24 @@ export function printStatusText(status) {
153
175
  }
154
176
  console.log(line);
155
177
  }
156
- if (status.isPlanningComplete) {
178
+ // Derived from the same inputs as the JSON `nextSteps` sentence, so the two
179
+ // surfaces always name the same command. Without this line the text surface
180
+ // reports state and no verb, which leaves someone resuming a change - after a
181
+ // lost session, or on a change they did not start - with nowhere to go.
182
+ const nextStep = resolveNextStep({
183
+ changeName: status.changeName,
184
+ artifactStatuses: status.artifacts,
185
+ allArtifactsComplete: status.isPlanningComplete,
186
+ ...(options.storeId ? { storeId: options.storeId } : {}),
187
+ });
188
+ if (status.isPlanningComplete || nextStep) {
157
189
  console.log();
190
+ }
191
+ if (status.isPlanningComplete) {
158
192
  console.log(chalk.green('All planning artifacts complete!'));
159
193
  }
194
+ if (nextStep) {
195
+ console.log(`Next: ${nextStep.command}`);
196
+ }
160
197
  }
161
198
  //# sourceMappingURL=status.js.map