@fission-ai/openspec 1.12.0 → 1.13.1

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 (105) hide show
  1. package/README.md +3 -15
  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 +170 -16
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.d.ts +8 -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 +17 -1
  17. package/dist/core/artifact-graph/schema.d.ts +14 -0
  18. package/dist/core/artifact-graph/schema.js +52 -0
  19. package/dist/core/artifact-graph/types.js +12 -1
  20. package/dist/core/change-status-policy.d.ts +14 -0
  21. package/dist/core/change-status-policy.js +14 -6
  22. package/dist/core/command-generation/generator.js +2 -0
  23. package/dist/core/completion-tip.js +7 -7
  24. package/dist/core/completions/installers/bash-installer.js +15 -7
  25. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  26. package/dist/core/completions/installers/shell-quote.js +13 -0
  27. package/dist/core/completions/installers/zsh-installer.js +3 -2
  28. package/dist/core/config-schema.d.ts +1 -1
  29. package/dist/core/config.d.ts +16 -0
  30. package/dist/core/config.js +27 -1
  31. package/dist/core/global-config.d.ts +30 -2
  32. package/dist/core/global-config.js +57 -3
  33. package/dist/core/init.js +33 -3
  34. package/dist/core/legacy-cleanup.d.ts +4 -0
  35. package/dist/core/legacy-cleanup.js +180 -12
  36. package/dist/core/list.js +55 -11
  37. package/dist/core/migration.js +6 -1
  38. package/dist/core/onboarding-commands.d.ts +11 -1
  39. package/dist/core/onboarding-commands.js +26 -0
  40. package/dist/core/parsers/change-parser.d.ts +12 -1
  41. package/dist/core/parsers/change-parser.js +85 -87
  42. package/dist/core/parsers/markdown-parser.js +4 -3
  43. package/dist/core/parsers/requirement-blocks.d.ts +44 -3
  44. package/dist/core/parsers/requirement-blocks.js +164 -43
  45. package/dist/core/parsers/requirement-text.d.ts +13 -2
  46. package/dist/core/parsers/requirement-text.js +34 -7
  47. package/dist/core/parsers/spec-structure.js +4 -1
  48. package/dist/core/project-config.d.ts +9 -1
  49. package/dist/core/project-config.js +11 -2
  50. package/dist/core/references.d.ts +20 -0
  51. package/dist/core/references.js +56 -0
  52. package/dist/core/shared/skill-content-equivalence.js +5 -1
  53. package/dist/core/shared/skill-generation.js +36 -8
  54. package/dist/core/shared/tool-detection.js +37 -4
  55. package/dist/core/specs-apply.js +260 -18
  56. package/dist/core/store/git.d.ts +33 -0
  57. package/dist/core/store/git.js +60 -6
  58. package/dist/core/store/operations.d.ts +1 -1
  59. package/dist/core/store/operations.js +32 -10
  60. package/dist/core/store/registry.d.ts +2 -1
  61. package/dist/core/store/registry.js +5 -1
  62. package/dist/core/templates/optional-workflow.d.ts +69 -0
  63. package/dist/core/templates/optional-workflow.js +140 -0
  64. package/dist/core/templates/workflows/apply-change.js +18 -3
  65. package/dist/core/templates/workflows/archive-change.js +50 -15
  66. package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
  67. package/dist/core/templates/workflows/continue-change.js +14 -2
  68. package/dist/core/templates/workflows/explore.js +48 -14
  69. package/dist/core/templates/workflows/ff-change.js +19 -3
  70. package/dist/core/templates/workflows/new-change.js +15 -3
  71. package/dist/core/templates/workflows/onboard.js +67 -37
  72. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  73. package/dist/core/templates/workflows/project-root.js +38 -0
  74. package/dist/core/templates/workflows/propose.js +52 -15
  75. package/dist/core/templates/workflows/sync-specs.js +40 -1
  76. package/dist/core/templates/workflows/update-change.js +52 -25
  77. package/dist/core/templates/workflows/verify-change.js +12 -3
  78. package/dist/core/update.d.ts +34 -0
  79. package/dist/core/update.js +109 -11
  80. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  81. package/dist/core/validation/task-checkboxes.js +181 -0
  82. package/dist/core/validation/validator.d.ts +53 -1
  83. package/dist/core/validation/validator.js +154 -40
  84. package/dist/core/version-check.d.ts +14 -7
  85. package/dist/core/version-check.js +63 -17
  86. package/dist/core/worksets.js +6 -4
  87. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  88. package/dist/prompts/searchable-multi-select.js +35 -6
  89. package/dist/telemetry/config.js +14 -2
  90. package/dist/telemetry/index.d.ts +4 -3
  91. package/dist/telemetry/index.js +22 -10
  92. package/dist/telemetry/opt-out.d.ts +17 -0
  93. package/dist/telemetry/opt-out.js +27 -0
  94. package/dist/utils/nested-change.d.ts +35 -0
  95. package/dist/utils/nested-change.js +186 -0
  96. package/dist/utils/spec-discovery.d.ts +20 -0
  97. package/dist/utils/spec-discovery.js +59 -0
  98. package/dist/utils/task-progress.d.ts +1 -1
  99. package/dist/utils/task-progress.js +38 -6
  100. package/package.json +4 -4
  101. package/schemas/spec-driven/schema.yaml +24 -5
  102. package/schemas/spec-driven/templates/design.md +2 -0
  103. package/schemas/spec-driven/templates/proposal.md +2 -0
  104. package/schemas/spec-driven/templates/spec.md +2 -0
  105. package/schemas/spec-driven/templates/tasks.md +2 -0
@@ -8,13 +8,16 @@ import ora from 'ora';
8
8
  import path from 'path';
9
9
  import * as fs from 'fs';
10
10
  import { loadChangeContext, generateInstructions, resolveSchema, resolveArtifactOutputPath, resolveArtifactOutputs, } from '../../core/artifact-graph/index.js';
11
+ import { isSpecsArtifactPath } from '../../core/artifact-graph/outputs.js';
12
+ import { findUnreadDeltaFiles } from '../../utils/spec-discovery.js';
11
13
  import { getChangeDir, resolveCurrentPlanningHomeSync, } from '../../core/planning-home.js';
12
14
  import { resolveRootForCommand, withStoreFlag, toPlanningHome, toRootOutput, } from '../../core/root-selection.js';
13
- import { assembleReferenceIndex, renderReferencedStoresBlock, renderReferencedStoresSection, } from '../../core/references.js';
15
+ import { assembleReferenceIndex, escapeEnvelopeAttribute, escapeEnvelopeTags, renderReferencedStoresBlock, renderReferencedStoresSection, sanitizeInline, } from '../../core/references.js';
14
16
  import { readRegistrySnapshot } from '../../core/store/registry.js';
15
17
  import { loadOperationInputs, readProjectConfig, } from '../../core/project-config.js';
16
18
  import { validateChangeExists, validateSchemaExists, } from './shared.js';
17
19
  import { parseTaskLines } from '../../utils/task-progress.js';
20
+ import { METADATA_FILENAME } from '../../utils/change-metadata.js';
18
21
  // -----------------------------------------------------------------------------
19
22
  // Artifact Instructions Command
20
23
  // -----------------------------------------------------------------------------
@@ -93,8 +96,12 @@ export async function instructionsCommand(artifactId, options) {
93
96
  }
94
97
  export function printInstructionsText(instructions, isBlocked) {
95
98
  const { artifactId, changeName, schemaName, changeDir, resolvedOutputPath, description, instruction, context, rules, template, dependencies, unlocks, } = instructions;
96
- // Opening tag
97
- 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)}">`);
98
105
  console.log();
99
106
  // Artifacts skipped via skip_specs get no creation directive: emitting the
100
107
  // task/template anyway would prompt an agent to write spec files that
@@ -118,15 +125,15 @@ export function printInstructionsText(instructions, isBlocked) {
118
125
  }
119
126
  // Task directive
120
127
  console.log('<task>');
121
- console.log(`Create the ${artifactId} artifact for change "${changeName}".`);
122
- console.log(description);
128
+ console.log(`Create the ${escapeEnvelopeTags(artifactId)} artifact for change "${escapeEnvelopeTags(changeName)}".`);
129
+ console.log(escapeEnvelopeTags(description));
123
130
  console.log('</task>');
124
131
  console.log();
125
132
  // Project context (AI constraint - do not include in output)
126
133
  if (context) {
127
134
  console.log('<project_context>');
128
135
  console.log('<!-- This is background information for you. Do NOT include this in your output. -->');
129
- console.log(context);
136
+ console.log(escapeEnvelopeTags(context));
130
137
  console.log('</project_context>');
131
138
  console.log();
132
139
  }
@@ -140,7 +147,9 @@ export function printInstructionsText(instructions, isBlocked) {
140
147
  console.log('<rules>');
141
148
  console.log('<!-- These are constraints for you to follow. Do NOT include this in your output. -->');
142
149
  for (const rule of rules) {
143
- 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))}`);
144
153
  }
145
154
  console.log('</rules>');
146
155
  console.log();
@@ -164,7 +173,7 @@ export function printInstructionsText(instructions, isBlocked) {
164
173
  const fullPath = path.join(changeDir, dep.path);
165
174
  console.log(`<dependency id="${dep.id}" status="${status}">`);
166
175
  console.log(` <path>${fullPath}</path>`);
167
- console.log(` <description>${dep.description}</description>`);
176
+ console.log(` <description>${escapeEnvelopeTags(dep.description)}</description>`);
168
177
  console.log('</dependency>');
169
178
  }
170
179
  console.log('</dependencies>');
@@ -178,14 +187,17 @@ export function printInstructionsText(instructions, isBlocked) {
178
187
  // Instruction (guidance)
179
188
  if (instruction) {
180
189
  console.log('<instruction>');
181
- console.log(instruction.trim());
190
+ console.log(escapeEnvelopeTags(instruction.trim()));
182
191
  console.log('</instruction>');
183
192
  console.log();
184
193
  }
185
194
  // Template
186
195
  console.log('<template>');
187
196
  console.log('<!-- Use this as the structure for your output file. Fill in the sections. -->');
188
- 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()));
189
201
  console.log('</template>');
190
202
  console.log();
191
203
  // Success criteria placeholder
@@ -230,6 +242,106 @@ function toTaskItems(parsed) {
230
242
  }
231
243
  return tasks;
232
244
  }
245
+ /**
246
+ * The command that builds one artifact.
247
+ *
248
+ * Every earlier remedy here named the `openspec-continue-change` skill, which
249
+ * the `core` profile never installs - the advice was a dead end for the default
250
+ * install. The CLI verb exists on every profile and is what the skill runs.
251
+ */
252
+ function describeArtifactRemedy(changeName, artifactId, options = {}) {
253
+ const target = artifactId ?? '<artifact>';
254
+ const verb = options.many ? 'Create each with' : 'Create it with';
255
+ return (`${verb} \`openspec instructions ${target} --change ${changeName}\`` +
256
+ ` (\`openspec status --change ${changeName}\` shows what is left).`);
257
+ }
258
+ /**
259
+ * Finds the artifact a schema path is generated by, so a remedy can name it.
260
+ */
261
+ function findArtifactIdFor(schema, generates) {
262
+ return schema.artifacts.find((artifact) => artifact.generates === generates)?.id;
263
+ }
264
+ /**
265
+ * Everything still to build before apply can run, in build order.
266
+ *
267
+ * Apply blocks on the schema's `apply.requires` alone, so its own list stops at
268
+ * the first hop: a change with only a proposal is told "Missing artifacts:
269
+ * tasks" while the specs `tasks` depends on are missing too. An agent that
270
+ * takes that literally writes the tracking file straight from the proposal and
271
+ * skips the artifacts in between - the failure reported in #834 and #869.
272
+ * Walking `requires` names the whole chain, the same set and order
273
+ * `openspec status` already prints, without changing what apply blocks on.
274
+ */
275
+ function collectMissingPrerequisites(input) {
276
+ const { requiredArtifactIds, schema, buildOrder, completed } = input;
277
+ const byId = new Map(schema.artifacts.map((artifact) => [artifact.id, artifact]));
278
+ const missing = new Set();
279
+ const queue = [...requiredArtifactIds];
280
+ const seen = new Set(queue);
281
+ while (queue.length > 0) {
282
+ const id = queue.shift();
283
+ const artifact = byId.get(id);
284
+ if (!artifact)
285
+ continue;
286
+ if (!completed.has(id))
287
+ missing.add(id);
288
+ for (const dependency of artifact.requires) {
289
+ if (seen.has(dependency))
290
+ continue;
291
+ seen.add(dependency);
292
+ queue.push(dependency);
293
+ }
294
+ }
295
+ const order = new Map(buildOrder.map((id, index) => [id, index]));
296
+ return [...missing].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));
297
+ }
298
+ /**
299
+ * Warnings apply reports alongside its instruction.
300
+ *
301
+ * Apply gates on the schema's `apply.requires` only, so a change whose tasks
302
+ * file was written ahead of its specs reads as ready even though no delta spec
303
+ * exists - the state `openspec validate` rejects. Blocking here would be a
304
+ * policy change; naming the gap is not, and it is what keeps apply from being
305
+ * the one surface that green-lights a change every other surface flags.
306
+ *
307
+ * Only reported once apply is past its own gate: for a change that has not
308
+ * reached tasks yet, the missing specs are the next step rather than a warning.
309
+ * Schemas that declare no spec-producing artifact carry `skip_specs` from
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.
315
+ */
316
+ async function collectApplyWarnings(input) {
317
+ const { state, schema, changeDir, changeName, skippedArtifacts } = input;
318
+ if (state === 'blocked')
319
+ return [];
320
+ const specArtifacts = schema.artifacts.filter((artifact) => isSpecsArtifactPath(artifact.generates));
321
+ if (specArtifacts.length === 0)
322
+ return [];
323
+ if (specArtifacts.some((artifact) => skippedArtifacts?.has(artifact.id)))
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}.`);
327
+ const hasDeltas = specArtifacts.some((artifact) => resolveArtifactOutputs(changeDir, artifact.generates).length > 0);
328
+ if (hasDeltas)
329
+ return warnings;
330
+ const metadataPath = path.join(changeDir, METADATA_FILENAME);
331
+ // The command names the artifact this schema actually declares, never the
332
+ // literal `specs`. A schema whose spec-producing artifact is `contracts` was
333
+ // told to run `openspec instructions specs`, an artifact it does not have,
334
+ // so the warning dead-ended at the exact step meant to resolve it. With more
335
+ // than one such artifact there is no single right answer, so the id becomes
336
+ // a placeholder rather than a guess.
337
+ const specTarget = specArtifacts.length === 1 ? specArtifacts[0].id : '<artifact-id>';
338
+ return [
339
+ ...warnings,
340
+ `This change has no delta specs and does not declare \`skip_specs: true\`, so \`openspec validate ${changeName}\` fails on it. ` +
341
+ `Write the delta specs before implementing (\`openspec instructions ${specTarget} --change ${changeName}\`), ` +
342
+ `or add \`skip_specs: true\` to ${metadataPath} if this change really changes no specified behavior.`,
343
+ ];
344
+ }
233
345
  /**
234
346
  * Generates apply instructions for implementing tasks from a change.
235
347
  * Schema-aware: reads apply phase configuration from schema to determine
@@ -267,6 +379,13 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
267
379
  missingArtifacts.push(artifactId);
268
380
  }
269
381
  }
382
+ // Everything still to build, not just the first hop apply blocks on.
383
+ const missingPrerequisites = collectMissingPrerequisites({
384
+ requiredArtifactIds: [...requiredArtifactIds],
385
+ schema,
386
+ buildOrder: context.graph.getBuildOrder(),
387
+ completed: context.completed,
388
+ });
270
389
  // Build context files from all existing artifacts in schema
271
390
  const contextFiles = {};
272
391
  for (const artifact of schema.artifacts) {
@@ -297,20 +416,33 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
297
416
  let instruction;
298
417
  if (missingArtifacts.length > 0) {
299
418
  state = 'blocked';
300
- instruction = `Cannot apply this change yet. Missing artifacts: ${missingArtifacts.join(', ')}.\nUse the openspec-continue-change skill to create the missing artifacts first.`;
419
+ const chain = missingPrerequisites.length > missingArtifacts.length
420
+ ? `\nNot created yet, in build order: ${missingPrerequisites.join(', ')}.` +
421
+ ` Build the ones this change needs before applying - the schema says which are conditional.`
422
+ : '';
423
+ instruction =
424
+ `Cannot apply this change yet. Missing artifacts: ${missingArtifacts.join(', ')}.${chain}` +
425
+ `\n${describeArtifactRemedy(changeName,
426
+ // Only name one when one is left: the first of several would be the
427
+ // schema's conditional artifact as often as not.
428
+ missingPrerequisites.length === 1 ? missingPrerequisites[0] : undefined, { many: missingPrerequisites.length > 1 })}`;
301
429
  }
302
430
  else if (tracksFile && !tracksFileExists) {
303
431
  // Tracking file configured but doesn't exist yet
304
432
  const tracksFilename = path.basename(tracksFile);
305
433
  state = 'blocked';
306
- instruction = `The ${tracksFilename} file is missing and must be created.\nUse openspec-continue-change to generate the tracking file.`;
434
+ instruction =
435
+ `The ${tracksFilename} file is missing and must be created.` +
436
+ `\n${describeArtifactRemedy(changeName, findArtifactIdFor(schema, tracksFile))}`;
307
437
  }
308
438
  else if (tracksFile && tracksFileExists && tasks.length === 0) {
309
439
  // Tracking file exists but lists nothing an agent can work on: either no
310
440
  // checkboxes at all, or only checkboxes with no text after them.
311
441
  const tracksFilename = path.basename(tracksFile);
312
442
  state = 'blocked';
313
- instruction = `The ${tracksFilename} file exists but contains no tasks to work on.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`;
443
+ instruction =
444
+ `The ${tracksFilename} file exists but contains no tasks to work on.` +
445
+ `\nAdd tasks to ${tracksFilename}, or rebuild it: ${describeArtifactRemedy(changeName, findArtifactIdFor(schema, tracksFile))}`;
314
446
  }
315
447
  else if (tracksFile && remaining === 0 && total > 0) {
316
448
  state = 'all_done';
@@ -325,6 +457,13 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
325
457
  state = 'ready';
326
458
  instruction = schemaInstruction?.trim() ?? 'Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.';
327
459
  }
460
+ const warnings = await collectApplyWarnings({
461
+ state,
462
+ schema,
463
+ changeDir,
464
+ changeName,
465
+ skippedArtifacts: context.skippedArtifacts,
466
+ });
328
467
  return {
329
468
  changeName,
330
469
  changeDir,
@@ -334,6 +473,8 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
334
473
  tasks,
335
474
  state,
336
475
  missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : undefined,
476
+ ...(missingPrerequisites.length > 0 ? { missingPrerequisites } : {}),
477
+ ...(warnings.length > 0 ? { warnings } : {}),
337
478
  instruction,
338
479
  ...(references !== undefined ? { references } : {}),
339
480
  ...operationInputs,
@@ -375,7 +516,7 @@ export async function applyInstructionsCommand(options) {
375
516
  }
376
517
  }
377
518
  export function printApplyInstructionsText(instructions) {
378
- const { changeName, schemaName, contextFiles, progress, tasks, state, missingArtifacts, instruction } = instructions;
519
+ const { changeName, schemaName, contextFiles, progress, tasks, state, missingArtifacts, warnings, instruction } = instructions;
379
520
  console.log(`## Apply: ${changeName}`);
380
521
  console.log(`Schema: ${schemaName}`);
381
522
  console.log();
@@ -388,7 +529,18 @@ export function printApplyInstructionsText(instructions) {
388
529
  console.log('### ⚠️ Blocked');
389
530
  console.log();
390
531
  console.log(`Missing artifacts: ${missingArtifacts.join(', ')}`);
391
- console.log('Use the openspec-continue-change skill to create these first.');
532
+ if (instructions.missingPrerequisites &&
533
+ instructions.missingPrerequisites.length > missingArtifacts.length) {
534
+ console.log(`Not created yet, in build order: ${instructions.missingPrerequisites.join(', ')}`);
535
+ }
536
+ console.log();
537
+ }
538
+ if (warnings && warnings.length > 0) {
539
+ console.log('### ⚠️ Warnings');
540
+ console.log();
541
+ for (const warning of warnings) {
542
+ console.log(`- ${warning}`);
543
+ }
392
544
  console.log();
393
545
  }
394
546
  // Context files (dynamically from schema)
@@ -464,13 +616,15 @@ export function printArchiveInstructionsText(instructions) {
464
616
  function printOperationInputsText(inputs) {
465
617
  if (inputs.context) {
466
618
  console.log('### Project Context (required instruction input)');
619
+ // Printed verbatim on purpose. Escaping a leading `#` would also fire inside
620
+ // fenced code (`# install deps`), so heading forgery is not guarded here.
467
621
  console.log(inputs.context);
468
622
  console.log();
469
623
  }
470
624
  if (inputs.operationGuidance && inputs.operationGuidance.length > 0) {
471
625
  console.log('### Operation Guidance (advisory)');
472
626
  for (const guidance of inputs.operationGuidance) {
473
- console.log(`- ${guidance}`);
627
+ console.log(`- ${sanitizeInline(guidance, Infinity)}`);
474
628
  }
475
629
  console.log();
476
630
  }
@@ -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();
@@ -30,6 +30,14 @@ export interface ApplyInstructions {
30
30
  tasks: TaskItem[];
31
31
  state: 'blocked' | 'all_done' | 'ready';
32
32
  missingArtifacts?: string[];
33
+ /**
34
+ * Everything still to build before apply can run, in build order - the
35
+ * transitive closure of the schema's `apply.requires`, so it can be longer
36
+ * than `missingArtifacts`, which stops at the first hop apply blocks on.
37
+ */
38
+ missingPrerequisites?: string[];
39
+ /** Non-blocking problems with the change, reported alongside the instruction. */
40
+ warnings?: string[];
33
41
  instruction: string;
34
42
  /** Referenced-store index (read-only upstream context; omitted when none declared) */
35
43
  references?: ReferenceIndexEntry[];
@@ -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
@@ -8,11 +8,12 @@ import { VALIDATION_MESSAGES } from './validation/constants.js';
8
8
  import chalk from 'chalk';
9
9
  import { emitStoreRootBanner, isRootSelectionError, resolveOpenSpecRoot, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from './root-selection.js';
10
10
  import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, retireSpec, finalizeRetiredSpec, } from './specs-apply.js';
11
- import { discoverSpecFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
11
+ import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
12
12
  import { METADATA_FILENAME, readRetireCapabilitiesMarker, readSkipSpecsMarker } from '../utils/change-metadata.js';
13
13
  import { confirmPrompt, isNonInteractivePromptError } from '../utils/interactive.js';
14
14
  import { FileSystemUtils } from '../utils/file-system.js';
15
15
  import { folderStyleNameProblem } from './id.js';
16
+ import { describeNestedChange, findNestedChangesIn, } from '../utils/nested-change.js';
16
17
  function isMissingPathError(error) {
17
18
  return (typeof error === 'object' &&
18
19
  error !== null &&
@@ -949,6 +950,14 @@ export class ArchiveCommand {
949
950
  ? `Change '${changeName}' not found. Available changes: ${available.join(', ')}`
950
951
  : `Change '${changeName}' not found. No active changes exist in this root.`);
951
952
  }
953
+ // Archiving a namespace folder moves an active, unfinished change into the
954
+ // archive under a name nobody will look for, and never applies its deltas.
955
+ // That is silent data loss, so it is refused outright rather than warned
956
+ // about (#1846).
957
+ const nested = await findNestedChangesIn(changesDir, changeName);
958
+ if (nested) {
959
+ throw new ArchiveBlockedError('archive_change_is_namespace_folder', `Cannot archive '${changeName}': ${describeNestedChange(nested)}`, `Rename openspec/changes/${nested.nested[0]}/ to a flat change directory, then archive it.`);
960
+ }
952
961
  const skipValidation = options.validate === false || options.noValidate === true;
953
962
  // Validate specs and change before archiving
954
963
  if (!skipValidation) {
@@ -993,6 +1002,13 @@ export class ArchiveCommand {
993
1002
  // folder, so only a regular file counts.
994
1003
  const rootSpecStat = await fs.stat(path.join(changeSpecsDir, 'spec.md')).catch(() => null);
995
1004
  let hasDeltaSpecs = rootSpecStat?.isFile() === true;
1005
+ // Likewise for delta sections in any other file the merge path does not
1006
+ // read (specs/<capability>.md, a note beside spec.md): without this the
1007
+ // zero-delta leniency below archives the change as done with nothing
1008
+ // merged, although validate rejects it.
1009
+ if (!hasDeltaSpecs) {
1010
+ hasDeltaSpecs = (await findUnreadDeltaFiles(changeSpecsDir)).length > 0;
1011
+ }
996
1012
  // A change that declares skip_specs must not carry any file under
997
1013
  // specs/ — validate reports that as a conflict, so archive has to run
998
1014
  // the same check instead of skipping validation because the files
@@ -10,4 +10,18 @@ export declare function loadSchema(filePath: string): SchemaYaml;
10
10
  * Parses and validates an artifact schema from YAML content.
11
11
  */
12
12
  export declare function parseSchema(yamlContent: string): SchemaYaml;
13
+ /**
14
+ * Describes an `apply.tracks` value that is not exactly equal to any artifact's
15
+ * `generates` value, or returns undefined when there is nothing to report.
16
+ *
17
+ * The tracked-tasks lookups select the artifact whose `generates` string equals
18
+ * `tracks`, so this is a progress-discovery problem, not a claim that nothing
19
+ * produces the file: a glob `generates: tasks/*.md` really does generate
20
+ * `tracks: tasks/main.md`, yet the strings differ, so the lookup still misses.
21
+ * Either way apply keeps working (it reads the path directly), but `openspec
22
+ * list` and `openspec status` fall back to counting the top-level `tasks.md`,
23
+ * and apply's remedy cannot name an artifact to build. A typo such as
24
+ * `task.md` is the other usual cause.
25
+ */
26
+ export declare function findApplyTracksWarning(schema: SchemaYaml): string | undefined;
13
27
  //# sourceMappingURL=schema.d.ts.map
@@ -30,6 +30,8 @@ export function parseSchema(yamlContent) {
30
30
  validateNoDuplicateIds(schema.artifacts);
31
31
  // Check that all requires references are valid
32
32
  validateRequiresReferences(schema.artifacts);
33
+ // Check that the apply phase names artifacts this schema declares
34
+ validateApplyReferences(schema);
33
35
  // Check for cycles
34
36
  validateNoCycles(schema.artifacts);
35
37
  return schema;
@@ -59,6 +61,56 @@ function validateRequiresReferences(artifacts) {
59
61
  }
60
62
  }
61
63
  }
64
+ /**
65
+ * Validates that every `apply.requires` id is an artifact the schema declares.
66
+ *
67
+ * Apply skips an id that no artifact declares, so a typo silently dropped that
68
+ * artifact from the apply gate. An unknown artifact `requires` is already a
69
+ * load error, and this is the same kind of reference.
70
+ *
71
+ * `apply.tracks` is deliberately not checked here. It is a path, not an id:
72
+ * apply reads it as written, so a schema whose `tracks` value does not exactly
73
+ * match any `generates` value (a hand-written `TODO.md`, or `tasks/main.md`
74
+ * under a glob `generates: tasks/*.md` that really does produce it) works
75
+ * today, and failing the load would break every command on it.
76
+ * `openspec schema validate` reports that case as a warning instead
77
+ * (see `findApplyTracksWarning`).
78
+ */
79
+ function validateApplyReferences(schema) {
80
+ const apply = schema.apply;
81
+ if (!apply)
82
+ return;
83
+ const validIds = schema.artifacts.map(a => a.id);
84
+ for (const req of apply.requires) {
85
+ if (!validIds.includes(req)) {
86
+ throw new SchemaValidationError(`Invalid apply.requires reference: '${req}' does not exist (artifacts: ${validIds.join(', ')})`);
87
+ }
88
+ }
89
+ }
90
+ /**
91
+ * Describes an `apply.tracks` value that is not exactly equal to any artifact's
92
+ * `generates` value, or returns undefined when there is nothing to report.
93
+ *
94
+ * The tracked-tasks lookups select the artifact whose `generates` string equals
95
+ * `tracks`, so this is a progress-discovery problem, not a claim that nothing
96
+ * produces the file: a glob `generates: tasks/*.md` really does generate
97
+ * `tracks: tasks/main.md`, yet the strings differ, so the lookup still misses.
98
+ * Either way apply keeps working (it reads the path directly), but `openspec
99
+ * list` and `openspec status` fall back to counting the top-level `tasks.md`,
100
+ * and apply's remedy cannot name an artifact to build. A typo such as
101
+ * `task.md` is the other usual cause.
102
+ */
103
+ export function findApplyTracksWarning(schema) {
104
+ const tracks = schema.apply?.tracks;
105
+ if (tracks == null || schema.artifacts.some(a => a.generates === tracks))
106
+ return undefined;
107
+ return (`apply.tracks '${tracks}' does not exactly match any artifact's generates value ` +
108
+ `(generates: ${schema.artifacts.map(a => a.generates).join(', ')}), ` +
109
+ `so OpenSpec cannot tell which artifact's progress it tracks. ` +
110
+ `Apply still reads that file as written, but list and status count tasks.md instead. ` +
111
+ `Make apply.tracks exactly equal one of those generates values, ` +
112
+ `or confirm that file is maintained outside the artifact graph.`);
113
+ }
62
114
  /**
63
115
  * Validates that there are no cyclic dependencies.
64
116
  * Uses DFS to detect cycles and reports the full cycle path.