@fission-ai/openspec 1.13.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 (102) hide show
  1. package/README.md +1 -1
  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 +33 -14
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.js +8 -0
  13. package/dist/commands/workflow/status.d.ts +5 -1
  14. package/dist/commands/workflow/status.js +42 -5
  15. package/dist/core/archive.js +17 -1
  16. package/dist/core/artifact-graph/schema.d.ts +14 -0
  17. package/dist/core/artifact-graph/schema.js +52 -0
  18. package/dist/core/artifact-graph/types.js +12 -1
  19. package/dist/core/change-status-policy.d.ts +14 -0
  20. package/dist/core/change-status-policy.js +14 -6
  21. package/dist/core/command-generation/generator.js +2 -0
  22. package/dist/core/completion-tip.js +7 -7
  23. package/dist/core/completions/installers/bash-installer.js +15 -7
  24. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  25. package/dist/core/completions/installers/shell-quote.js +13 -0
  26. package/dist/core/completions/installers/zsh-installer.js +3 -2
  27. package/dist/core/config-schema.d.ts +1 -1
  28. package/dist/core/config.d.ts +16 -0
  29. package/dist/core/config.js +27 -1
  30. package/dist/core/global-config.d.ts +30 -2
  31. package/dist/core/global-config.js +57 -3
  32. package/dist/core/init.js +13 -3
  33. package/dist/core/legacy-cleanup.d.ts +4 -0
  34. package/dist/core/legacy-cleanup.js +180 -12
  35. package/dist/core/list.js +55 -11
  36. package/dist/core/migration.js +6 -1
  37. package/dist/core/parsers/change-parser.d.ts +12 -1
  38. package/dist/core/parsers/change-parser.js +85 -87
  39. package/dist/core/parsers/markdown-parser.js +4 -3
  40. package/dist/core/parsers/requirement-blocks.d.ts +44 -3
  41. package/dist/core/parsers/requirement-blocks.js +90 -15
  42. package/dist/core/parsers/requirement-text.d.ts +13 -2
  43. package/dist/core/parsers/requirement-text.js +34 -7
  44. package/dist/core/parsers/spec-structure.js +4 -1
  45. package/dist/core/project-config.d.ts +9 -1
  46. package/dist/core/project-config.js +11 -2
  47. package/dist/core/references.d.ts +20 -0
  48. package/dist/core/references.js +56 -0
  49. package/dist/core/shared/skill-content-equivalence.js +5 -1
  50. package/dist/core/shared/skill-generation.js +36 -8
  51. package/dist/core/shared/tool-detection.js +9 -3
  52. package/dist/core/specs-apply.js +82 -11
  53. package/dist/core/store/git.d.ts +33 -0
  54. package/dist/core/store/git.js +60 -6
  55. package/dist/core/store/operations.d.ts +1 -1
  56. package/dist/core/store/operations.js +32 -10
  57. package/dist/core/store/registry.d.ts +2 -1
  58. package/dist/core/store/registry.js +5 -1
  59. package/dist/core/templates/optional-workflow.d.ts +69 -0
  60. package/dist/core/templates/optional-workflow.js +140 -0
  61. package/dist/core/templates/workflows/apply-change.js +18 -3
  62. package/dist/core/templates/workflows/archive-change.js +50 -15
  63. package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
  64. package/dist/core/templates/workflows/continue-change.js +14 -2
  65. package/dist/core/templates/workflows/explore.js +32 -14
  66. package/dist/core/templates/workflows/ff-change.js +19 -3
  67. package/dist/core/templates/workflows/new-change.js +15 -3
  68. package/dist/core/templates/workflows/onboard.js +67 -37
  69. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  70. package/dist/core/templates/workflows/project-root.js +38 -0
  71. package/dist/core/templates/workflows/propose.js +22 -5
  72. package/dist/core/templates/workflows/sync-specs.js +40 -1
  73. package/dist/core/templates/workflows/update-change.js +52 -25
  74. package/dist/core/templates/workflows/verify-change.js +12 -3
  75. package/dist/core/update.d.ts +12 -0
  76. package/dist/core/update.js +58 -3
  77. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  78. package/dist/core/validation/task-checkboxes.js +181 -0
  79. package/dist/core/validation/validator.d.ts +53 -1
  80. package/dist/core/validation/validator.js +154 -40
  81. package/dist/core/version-check.d.ts +14 -7
  82. package/dist/core/version-check.js +63 -17
  83. package/dist/core/worksets.js +6 -4
  84. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  85. package/dist/prompts/searchable-multi-select.js +35 -6
  86. package/dist/telemetry/config.js +14 -2
  87. package/dist/telemetry/index.d.ts +4 -3
  88. package/dist/telemetry/index.js +22 -10
  89. package/dist/telemetry/opt-out.d.ts +17 -0
  90. package/dist/telemetry/opt-out.js +27 -0
  91. package/dist/utils/nested-change.d.ts +35 -0
  92. package/dist/utils/nested-change.js +186 -0
  93. package/dist/utils/spec-discovery.d.ts +20 -0
  94. package/dist/utils/spec-discovery.js +59 -0
  95. package/dist/utils/task-progress.d.ts +1 -1
  96. package/dist/utils/task-progress.js +38 -6
  97. package/package.json +4 -4
  98. package/schemas/spec-driven/schema.yaml +10 -3
  99. package/schemas/spec-driven/templates/design.md +2 -0
  100. package/schemas/spec-driven/templates/proposal.md +2 -0
  101. package/schemas/spec-driven/templates/spec.md +2 -0
  102. package/schemas/spec-driven/templates/tasks.md +2 -0
@@ -9,9 +9,10 @@ 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
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.`,
@@ -440,7 +457,7 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
440
457
  state = 'ready';
441
458
  instruction = schemaInstruction?.trim() ?? 'Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.';
442
459
  }
443
- const warnings = collectApplyWarnings({
460
+ const warnings = await collectApplyWarnings({
444
461
  state,
445
462
  schema,
446
463
  changeDir,
@@ -599,13 +616,15 @@ export function printArchiveInstructionsText(instructions) {
599
616
  function printOperationInputsText(inputs) {
600
617
  if (inputs.context) {
601
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.
602
621
  console.log(inputs.context);
603
622
  console.log();
604
623
  }
605
624
  if (inputs.operationGuidance && inputs.operationGuidance.length > 0) {
606
625
  console.log('### Operation Guidance (advisory)');
607
626
  for (const guidance of inputs.operationGuidance) {
608
- console.log(`- ${guidance}`);
627
+ console.log(`- ${sanitizeInline(guidance, Infinity)}`);
609
628
  }
610
629
  console.log();
611
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();
@@ -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.
@@ -18,6 +18,9 @@ function relativePathSchema(fieldName) {
18
18
  });
19
19
  }
20
20
  // Artifact definition schema
21
+ // Upper bound on artifacts in one schema. Keeps `validateNoCycles`' recursive
22
+ // DFS well inside the stack limit for any accepted input.
23
+ const MAX_ARTIFACTS = 1000;
21
24
  export const ArtifactSchema = z.object({
22
25
  id: z.string().min(1, { error: 'Artifact ID is required' }),
23
26
  generates: relativePathSchema('generates field'),
@@ -40,7 +43,15 @@ export const SchemaYamlSchema = z.object({
40
43
  name: z.string().min(1, { error: 'Schema name is required' }),
41
44
  version: z.number().int().positive({ error: 'Version must be a positive integer' }),
42
45
  description: z.string().optional(),
43
- artifacts: z.array(ArtifactSchema).min(1, { error: 'At least one artifact required' }),
46
+ artifacts: z
47
+ .array(ArtifactSchema)
48
+ .min(1, { error: 'At least one artifact required' })
49
+ // Bounded so a hostile schema cannot drive the cycle-detection DFS past the
50
+ // V8 stack limit and crash with an uncaught RangeError instead of a
51
+ // validation error.
52
+ .max(MAX_ARTIFACTS, {
53
+ error: `A schema may declare at most ${MAX_ARTIFACTS} artifacts`,
54
+ }),
44
55
  // Optional apply phase configuration (for schema-aware apply instructions)
45
56
  apply: ApplyPhaseSchema.optional(),
46
57
  });
@@ -33,5 +33,19 @@ export interface ActionContextInput {
33
33
  }
34
34
  export declare function summarizePlanningHome(planningHome: PlanningHome | undefined): PlanningHomeSummary | undefined;
35
35
  export declare function buildActionContext(input: ActionContextInput): ActionContext;
36
+ /**
37
+ * The one next action for a change, in both the forms the CLI needs.
38
+ *
39
+ * `sentence` is what the JSON `nextSteps` contract publishes; `command` is the
40
+ * bare command the text surface prints. Both are built here so the two
41
+ * surfaces can never name a different next step.
42
+ */
43
+ export interface ChangeNextStep {
44
+ /** Ready-to-run command, including any `--store` flag. */
45
+ command: string;
46
+ /** Sentence form carried by the JSON `nextSteps` array. */
47
+ sentence: string;
48
+ }
49
+ export declare function resolveNextStep(input: ChangeNextStepsInput): ChangeNextStep | undefined;
36
50
  export declare function buildNextSteps(input: ChangeNextStepsInput): string[];
37
51
  //# sourceMappingURL=change-status-policy.d.ts.map
@@ -20,16 +20,24 @@ export function buildActionContext(input) {
20
20
  constraints: ['Repo-local change artifacts and implementation edits are scoped to this project.'],
21
21
  };
22
22
  }
23
- export function buildNextSteps(input) {
23
+ export function resolveNextStep(input) {
24
24
  const readyArtifact = input.artifactStatuses.find((artifact) => artifact.status === 'ready');
25
- const steps = [];
26
25
  const storeFlag = input.storeId ? ` --store ${input.storeId}` : '';
27
26
  if (readyArtifact) {
28
- steps.push(`Run openspec instructions ${readyArtifact.id} --change "${input.changeName}"${storeFlag} --json before writing that artifact.`);
27
+ const command = `openspec instructions ${readyArtifact.id} --change "${input.changeName}"${storeFlag} --json`;
28
+ return { command, sentence: `Run ${command} before writing that artifact.` };
29
29
  }
30
- else if (input.allArtifactsComplete) {
31
- steps.push(`All planning artifacts are complete. Run openspec instructions apply --change "${input.changeName}"${storeFlag} --json to inspect implementation progress.`);
30
+ if (input.allArtifactsComplete) {
31
+ const command = `openspec instructions apply --change "${input.changeName}"${storeFlag} --json`;
32
+ return {
33
+ command,
34
+ sentence: `All planning artifacts are complete. Run ${command} to inspect implementation progress.`,
35
+ };
32
36
  }
33
- return steps;
37
+ return undefined;
38
+ }
39
+ export function buildNextSteps(input) {
40
+ const step = resolveNextStep(input);
41
+ return step ? [step.sentence] : [];
34
42
  }
35
43
  //# sourceMappingURL=change-status-policy.js.map
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { getInvocationForAdapter, needsInvocationRewrite } from './invocation.js';
7
7
  import { transformCommandInvocations } from '../../utils/command-references.js';
8
+ import { assertWorkflowConditionalsResolved } from '../templates/optional-workflow.js';
8
9
  /**
9
10
  * Generate a single command file using the provided adapter.
10
11
  *
@@ -20,6 +21,7 @@ import { transformCommandInvocations } from '../../utils/command-references.js';
20
21
  * @returns Generated command with path and file content
21
22
  */
22
23
  export function generateCommand(content, adapter) {
24
+ assertWorkflowConditionalsResolved(content.body, `Command '${content.id}' was generated without resolving its optional-workflow blocks`);
23
25
  const invocation = getInvocationForAdapter(adapter);
24
26
  const formatted = needsInvocationRewrite(invocation)
25
27
  ? { ...content, body: transformCommandInvocations(content.body, invocation) }
@@ -18,8 +18,8 @@
18
18
  * non-TTY runs, which are deferred rather than consumed (see `silent`)
19
19
  */
20
20
  import * as fs from 'node:fs';
21
- import * as path from 'node:path';
22
21
  import { getGlobalConfigPath } from './global-config.js';
22
+ import { writeFileAtomically } from './file-state.js';
23
23
  import { isCiEnvironment } from '../utils/ci.js';
24
24
  import { detectShell } from '../utils/shell-detection.js';
25
25
  import { CompletionFactory } from './completions/factory.js';
@@ -94,13 +94,13 @@ function readRawConfig() {
94
94
  * write down to this one key, and the rename keeps a reader from ever seeing a
95
95
  * half-written config.
96
96
  */
97
- function markTipSeen() {
97
+ async function markTipSeen() {
98
98
  const configPath = getGlobalConfigPath();
99
99
  const current = readRawConfig() ?? {};
100
- const tempPath = `${configPath}.${process.pid}.tmp`;
101
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
102
- fs.writeFileSync(tempPath, JSON.stringify({ ...current, completionTipSeen: true }, null, 2) + '\n', 'utf-8');
103
- fs.renameSync(tempPath, configPath);
100
+ // The shared atomic writer: randomized temp name, owner-only mode, temp file
101
+ // removed on failure. A predictable `<config>.<pid>.tmp` at the default mode
102
+ // is both guessable and world-readable once renamed over the config.
103
+ await writeFileAtomically(configPath, JSON.stringify({ ...current, completionTipSeen: true }, null, 2) + '\n');
104
104
  }
105
105
  /**
106
106
  * Print the completion tip once, the first time the CLI runs.
@@ -121,7 +121,7 @@ export async function maybeShowCompletionTip(options = {}) {
121
121
  const decision = await decideTip();
122
122
  // Record before printing: if the flag cannot be persisted, staying quiet
123
123
  // beats reprinting the tip on every future run.
124
- markTipSeen();
124
+ await markTipSeen();
125
125
  if (decision === 'show') {
126
126
  console.error(`\n${COMPLETION_TIP_MESSAGE}`);
127
127
  }
@@ -2,6 +2,7 @@ import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
4
  import { FileSystemUtils } from '../../../utils/file-system.js';
5
+ import { shellSingleQuote } from './shell-quote.js';
5
6
  /**
6
7
  * Installer for Bash completion scripts.
7
8
  * Supports bash-completion package and standalone installations.
@@ -105,10 +106,11 @@ export class BashInstaller {
105
106
  * @returns Configuration content
106
107
  */
107
108
  generateBashrcConfig(completionsDir) {
109
+ const quotedDir = shellSingleQuote(completionsDir);
108
110
  return [
109
111
  '# OpenSpec shell completions configuration',
110
- `if [ -d "${completionsDir}" ]; then`,
111
- ` for f in "${completionsDir}"/*; do`,
112
+ `if [ -d ${quotedDir} ]; then`,
113
+ ` for f in ${quotedDir}/*; do`,
112
114
  ' [ -f "$f" ] && . "$f"',
113
115
  ' done',
114
116
  'fi',
@@ -177,9 +179,11 @@ export class BashInstaller {
177
179
  }
178
180
  // Remove lines between markers (inclusive)
179
181
  lines.splice(startIndex, endIndex - startIndex + 1);
180
- // Remove trailing empty lines
181
- while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
182
- lines.pop();
182
+ // Install puts the block at the top of the file followed by one blank
183
+ // separator line; drop that line too so the file reads as it did before.
184
+ // Everything else, including the file's final newline, is left as is.
185
+ if (startIndex === 0 && lines.length > 0 && lines[0].trim() === '') {
186
+ lines.shift();
183
187
  }
184
188
  // Write back
185
189
  await fs.writeFile(bashrcPath, lines.join('\n'), 'utf-8');
@@ -281,14 +285,18 @@ export class BashInstaller {
281
285
  */
282
286
  generateInstructions(installedPath) {
283
287
  const completionsDir = path.dirname(installedPath);
288
+ // Quoted exactly like the auto-configured block: these lines are printed
289
+ // for the user to paste into their own rc file, so an expansion left in
290
+ // them runs on every future shell start.
291
+ const quotedDir = shellSingleQuote(completionsDir);
284
292
  return [
285
293
  'Completion script installed successfully.',
286
294
  '',
287
295
  'To enable completions, add the following to your ~/.bashrc file:',
288
296
  '',
289
297
  ` # Source OpenSpec completions`,
290
- ` if [ -d "${completionsDir}" ]; then`,
291
- ` for f in "${completionsDir}"/*; do`,
298
+ ` if [ -d ${quotedDir} ]; then`,
299
+ ` for f in ${quotedDir}/*; do`,
292
300
  ' [ -f "$f" ] && . "$f"',
293
301
  ' done',
294
302
  ' fi',