@fission-ai/openspec 1.13.2 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/cli/index.js +39 -1
  2. package/dist/commands/spec.js +1 -0
  3. package/dist/commands/workflow/instructions.js +31 -11
  4. package/dist/commands/workflow/shared.d.ts +2 -0
  5. package/dist/commands/workflow/status.js +15 -2
  6. package/dist/core/archive.js +15 -2
  7. package/dist/core/artifact-graph/instruction-loader.d.ts +7 -0
  8. package/dist/core/artifact-graph/instruction-loader.js +18 -1
  9. package/dist/core/change-metadata/schema.d.ts +3 -0
  10. package/dist/core/change-metadata/schema.js +10 -0
  11. package/dist/core/change-status-policy.d.ts +8 -0
  12. package/dist/core/change-status-policy.js +33 -2
  13. package/dist/core/command-generation/adapters/atomcode.d.ts +24 -0
  14. package/dist/core/command-generation/adapters/atomcode.js +46 -0
  15. package/dist/core/command-generation/adapters/codestudio.d.ts +13 -0
  16. package/dist/core/command-generation/adapters/codestudio.js +27 -0
  17. package/dist/core/command-generation/adapters/easycode.d.ts +17 -0
  18. package/dist/core/command-generation/adapters/easycode.js +28 -0
  19. package/dist/core/command-generation/adapters/gemini.js +1 -35
  20. package/dist/core/command-generation/adapters/gigacode.d.ts +17 -0
  21. package/dist/core/command-generation/adapters/gigacode.js +31 -0
  22. package/dist/core/command-generation/adapters/index.d.ts +4 -0
  23. package/dist/core/command-generation/adapters/index.js +4 -0
  24. package/dist/core/command-generation/registry.js +8 -0
  25. package/dist/core/command-generation/toml.d.ts +19 -0
  26. package/dist/core/command-generation/toml.js +39 -0
  27. package/dist/core/command-surface.js +1 -1
  28. package/dist/core/completions/command-registry.js +19 -0
  29. package/dist/core/completions/installers/zsh-installer.js +4 -2
  30. package/dist/core/config-prompts.js +6 -6
  31. package/dist/core/config.js +11 -1
  32. package/dist/core/init.js +35 -11
  33. package/dist/core/legacy-cleanup.js +11 -7
  34. package/dist/core/list.d.ts +2 -0
  35. package/dist/core/list.js +49 -23
  36. package/dist/core/parsers/markdown-parser.js +5 -0
  37. package/dist/core/parsers/requirement-blocks.d.ts +6 -0
  38. package/dist/core/parsers/requirement-blocks.js +9 -2
  39. package/dist/core/project-config.js +45 -1
  40. package/dist/core/references.js +3 -1
  41. package/dist/core/root-selection.d.ts +6 -0
  42. package/dist/core/root-selection.js +16 -0
  43. package/dist/core/schemas/base.schema.d.ts +3 -0
  44. package/dist/core/schemas/base.schema.js +6 -0
  45. package/dist/core/schemas/change.schema.d.ts +8 -0
  46. package/dist/core/schemas/spec.schema.d.ts +2 -0
  47. package/dist/core/templates/workflows/apply-change.js +5 -2
  48. package/dist/core/templates/workflows/archive-change.js +26 -4
  49. package/dist/core/templates/workflows/bulk-archive-change.js +6 -2
  50. package/dist/core/validation/constants.d.ts +1 -1
  51. package/dist/core/validation/constants.js +1 -1
  52. package/dist/core/validation/validator.js +9 -1
  53. package/dist/core/version-check.d.ts +23 -1
  54. package/dist/core/version-check.js +112 -26
  55. package/dist/core/view.d.ts +2 -0
  56. package/dist/core/view.js +62 -4
  57. package/dist/utils/change-metadata.d.ts +20 -1
  58. package/dist/utils/change-metadata.js +57 -1
  59. package/dist/utils/command-references.d.ts +1 -1
  60. package/dist/utils/command-references.js +2 -2
  61. package/dist/utils/index.d.ts +1 -1
  62. package/dist/utils/index.js +1 -1
  63. package/package.json +1 -1
  64. package/schemas/spec-driven/schema.yaml +2 -1
  65. package/schemas/spec-driven/templates/proposal.md +6 -3
@@ -136,4 +136,10 @@ export declare function findMissingCurrentScenarios(current: RequirementBlock, i
136
136
  * the counts a reader sees cannot differ between the two commands.
137
137
  */
138
138
  export declare function describeScenarioBalance(diff: ScenarioNameDiff): string;
139
+ /**
140
+ * scenarioNameAt for header text whose leading `####` is already gone, as the
141
+ * section parser (MarkdownParser) holds it, so `show --json` names a scenario
142
+ * exactly as the MODIFIED loss check does.
143
+ */
144
+ export declare function scenarioNameFromHeaderText(headerText: string): string;
139
145
  //# sourceMappingURL=requirement-blocks.d.ts.map
@@ -471,8 +471,15 @@ function scenarioHeaderAt(lines, mask, index) {
471
471
  * ATX-closed, one not) are not mistaken for a dropped scenario.
472
472
  */
473
473
  function scenarioNameAt(line) {
474
- return line
475
- .replace(SCENARIO_HEADER, '')
474
+ return scenarioNameFromHeaderText(line.replace(SCENARIO_HEADER, ''));
475
+ }
476
+ /**
477
+ * scenarioNameAt for header text whose leading `####` is already gone, as the
478
+ * section parser (MarkdownParser) holds it, so `show --json` names a scenario
479
+ * exactly as the MODIFIED loss check does.
480
+ */
481
+ export function scenarioNameFromHeaderText(headerText) {
482
+ return headerText
476
483
  // Optional ATX closing sequence. CommonMark only treats a trailing `#` run
477
484
  // as a close when it is preceded by a space or tab — not any Unicode space —
478
485
  // so this uses `[ \t]`, not `\s`. A looser `\s` could strip a `#` run after
@@ -181,6 +181,50 @@ function parseDeclarationList(raw) {
181
181
  return byId.size > 0 ? [...byId.values()] : undefined;
182
182
  }
183
183
  export const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit, shared with the references index
184
+ /**
185
+ * Build the warning for an artifact whose `rules:` list is not an array of
186
+ * strings. Names the offending index and what YAML actually produced there, so
187
+ * a config that silently loses an entire rule set can be fixed without
188
+ * bisecting the list by hand. A bare `-` item containing an unquoted ": " is the
189
+ * common cause: YAML reads it as a mapping, so the hint points at quoting.
190
+ */
191
+ function describeRulesShapeError(artifactId, rules) {
192
+ const base = `Rules for '${artifactId}' must be an array of strings, ignoring this artifact's rules`;
193
+ if (!Array.isArray(rules)) {
194
+ return `${base}. rules.${artifactId} is ${describeYamlType(rules)}`;
195
+ }
196
+ const bad = rules
197
+ .map((rule, index) => ({ rule, index }))
198
+ .filter(({ rule }) => typeof rule !== 'string');
199
+ if (bad.length === 0) {
200
+ return base;
201
+ }
202
+ // Name every offending index with its own shape, so a mixed list does not
203
+ // have to be re-bisected one item at a time.
204
+ const details = bad
205
+ .map(({ rule, index }) => `rules.${artifactId}[${index}] is ${describeYamlType(rule)}`)
206
+ .join('; ');
207
+ // A bare `-` item with an unquoted ": " is the common cause: YAML reads it as
208
+ // a mapping. Say so rather than leaving the reader to work out the quoting.
209
+ const hasMapping = bad.some(({ rule }) => rule !== null && typeof rule === 'object' && !Array.isArray(rule));
210
+ const hint = hasMapping
211
+ ? ' — an unquoted ": " makes YAML read the item as a key/value pair; quote the whole scalar to keep it a string.'
212
+ : '';
213
+ return `${base}. ${details}${hint}`;
214
+ }
215
+ /** Name a YAML value's shape in a warning, e.g. "a mapping" or "a number". */
216
+ function describeYamlType(value) {
217
+ if (value === null)
218
+ return 'null';
219
+ if (Array.isArray(value))
220
+ return 'a nested list';
221
+ const type = typeof value;
222
+ if (type === 'object')
223
+ return 'a mapping';
224
+ if (type === 'number' || type === 'boolean')
225
+ return `a ${type}`;
226
+ return `a ${type}`;
227
+ }
184
228
  /**
185
229
  * Read and parse openspec/config.yaml from project root.
186
230
  * Uses resilient parsing - validates each field independently using Zod safeParse.
@@ -265,7 +309,7 @@ export function readProjectConfig(projectRoot) {
265
309
  }
266
310
  }
267
311
  else {
268
- console.warn(`Rules for '${artifactId}' must be an array of strings, ignoring this artifact's rules`);
312
+ console.warn(describeRulesShapeError(artifactId, rules));
269
313
  }
270
314
  }
271
315
  if (hasValidRules) {
@@ -191,7 +191,9 @@ export function renderReferencedStoresSection(entries) {
191
191
  * let hostile content forge instruction lines (slice 6.1 hardening).
192
192
  */
193
193
  export function sanitizeInline(value, maxLength = 300) {
194
- const flattened = value.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
194
+ const flattened = value
195
+ .replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u206f]+/g, ' ')
196
+ .trim();
195
197
  return flattened.length > maxLength ? `${flattened.slice(0, maxLength)}…` : flattened;
196
198
  }
197
199
  /**
@@ -94,6 +94,12 @@ export declare function toRootOutput(root: ResolvedOpenSpecRoot): RootOutput;
94
94
  export declare function isStoreSelectedRoot(root: ResolvedOpenSpecRoot): root is ResolvedOpenSpecRoot & {
95
95
  storeId: string;
96
96
  };
97
+ /**
98
+ * The project on the current path whose `store:` pointer names `storeId`,
99
+ * as a canonical path (the root walk resolves aliases). Null when the
100
+ * nearest root is a real planning root or points at a different store.
101
+ */
102
+ export declare function findDeclaringProjectRoot(storeId: string, startPath?: string): string | null;
97
103
  /**
98
104
  * Human-mode verification signal for a selected store. Written to stderr so
99
105
  * raw-Markdown and agent-consumed stdout payloads stay clean.
@@ -309,6 +309,22 @@ export function toRootOutput(root) {
309
309
  export function isStoreSelectedRoot(root) {
310
310
  return root.storeId !== undefined;
311
311
  }
312
+ /**
313
+ * The project on the current path whose `store:` pointer names `storeId`,
314
+ * as a canonical path (the root walk resolves aliases). Null when the
315
+ * nearest root is a real planning root or points at a different store.
316
+ */
317
+ export function findDeclaringProjectRoot(storeId, startPath = process.cwd()) {
318
+ const nearestRoot = findQualifyingRootSync(startPath);
319
+ if (!nearestRoot) {
320
+ return null;
321
+ }
322
+ const { hasPlanningShape, pointer } = classifyOpenSpecDir(nearestRoot);
323
+ if (hasPlanningShape || pointer.value !== storeId) {
324
+ return null;
325
+ }
326
+ return nearestRoot;
327
+ }
312
328
  /**
313
329
  * Human-mode verification signal for a selected store. Written to stderr so
314
330
  * raw-Markdown and agent-consumed stdout payloads stay clean.
@@ -1,10 +1,13 @@
1
1
  import { z } from 'zod';
2
2
  export declare const ScenarioSchema: z.ZodObject<{
3
+ name: z.ZodOptional<z.ZodString>;
3
4
  rawText: z.ZodString;
4
5
  }, z.core.$strip>;
5
6
  export declare const RequirementSchema: z.ZodObject<{
7
+ name: z.ZodOptional<z.ZodString>;
6
8
  text: z.ZodString;
7
9
  scenarios: z.ZodArray<z.ZodObject<{
10
+ name: z.ZodOptional<z.ZodString>;
8
11
  rawText: z.ZodString;
9
12
  }, z.core.$strip>>;
10
13
  }, z.core.$strip>;
@@ -1,9 +1,15 @@
1
1
  import { z } from 'zod';
2
2
  import { VALIDATION_MESSAGES } from '../validation/constants.js';
3
3
  export const ScenarioSchema = z.object({
4
+ // Header text without `####`, the closing `#` run, and the `Scenario:`
5
+ // prefix. Optional so objects built outside the parser still validate.
6
+ name: z.string().optional(),
4
7
  rawText: z.string().min(1, VALIDATION_MESSAGES.SCENARIO_EMPTY),
5
8
  });
6
9
  export const RequirementSchema = z.object({
10
+ // Header text without `###` and the `Requirement:` prefix: the name archive
11
+ // matches MODIFIED, REMOVED and RENAMED entries against.
12
+ name: z.string().optional(),
7
13
  // SHALL/MUST body-keyword enforcement lives in the imperative validator
8
14
  // (Validator.applySpecRules), not here: the parser collapses the requirement
9
15
  // header into `text`, so a Zod refine on `text` cannot tell "keyword in header
@@ -15,14 +15,18 @@ export declare const DeltaSchema: z.ZodObject<{
15
15
  }>;
16
16
  description: z.ZodString;
17
17
  requirement: z.ZodOptional<z.ZodObject<{
18
+ name: z.ZodOptional<z.ZodString>;
18
19
  text: z.ZodString;
19
20
  scenarios: z.ZodArray<z.ZodObject<{
21
+ name: z.ZodOptional<z.ZodString>;
20
22
  rawText: z.ZodString;
21
23
  }, z.core.$strip>>;
22
24
  }, z.core.$strip>>;
23
25
  requirements: z.ZodOptional<z.ZodArray<z.ZodObject<{
26
+ name: z.ZodOptional<z.ZodString>;
24
27
  text: z.ZodString;
25
28
  scenarios: z.ZodArray<z.ZodObject<{
29
+ name: z.ZodOptional<z.ZodString>;
26
30
  rawText: z.ZodString;
27
31
  }, z.core.$strip>>;
28
32
  }, z.core.$strip>>>;
@@ -45,14 +49,18 @@ export declare const ChangeSchema: z.ZodObject<{
45
49
  }>;
46
50
  description: z.ZodString;
47
51
  requirement: z.ZodOptional<z.ZodObject<{
52
+ name: z.ZodOptional<z.ZodString>;
48
53
  text: z.ZodString;
49
54
  scenarios: z.ZodArray<z.ZodObject<{
55
+ name: z.ZodOptional<z.ZodString>;
50
56
  rawText: z.ZodString;
51
57
  }, z.core.$strip>>;
52
58
  }, z.core.$strip>>;
53
59
  requirements: z.ZodOptional<z.ZodArray<z.ZodObject<{
60
+ name: z.ZodOptional<z.ZodString>;
54
61
  text: z.ZodString;
55
62
  scenarios: z.ZodArray<z.ZodObject<{
63
+ name: z.ZodOptional<z.ZodString>;
56
64
  rawText: z.ZodString;
57
65
  }, z.core.$strip>>;
58
66
  }, z.core.$strip>>>;
@@ -3,8 +3,10 @@ export declare const SpecSchema: z.ZodObject<{
3
3
  name: z.ZodString;
4
4
  overview: z.ZodString;
5
5
  requirements: z.ZodArray<z.ZodObject<{
6
+ name: z.ZodOptional<z.ZodString>;
6
7
  text: z.ZodString;
7
8
  scenarios: z.ZodArray<z.ZodObject<{
9
+ name: z.ZodOptional<z.ZodString>;
8
10
  rawText: z.ZodString;
9
11
  }, z.core.$strip>>;
10
12
  }, z.core.$strip>>;
@@ -56,7 +56,7 @@ ${PROJECT_ROOT_GUARD}
56
56
  This returns:
57
57
  - \`contextFiles\`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
58
58
  - Progress (total, complete, remaining)
59
- - Task list with status
59
+ - Task list with status, source path, and source line
60
60
  - Dynamic instruction based on current state
61
61
  - Optional \`context\`: current required project instruction input from the selected root
62
62
  - Optional \`operationGuidance\`: current advisory guidance for apply
@@ -108,7 +108,9 @@ ${PROJECT_ROOT_GUARD}
108
108
  - Show which task is being worked on
109
109
  - Make the code changes required
110
110
  - Keep changes minimal and focused
111
- - Mark task complete in the tasks file: \`- [ ]\` → \`- [x]\`
111
+ - Before editing, confirm the checkbox at the returned \`sourcePath\` and \`line\` still matches the task description; if it does not, rerun the apply instructions and use the refreshed location
112
+ - Mark the task complete at its returned \`sourcePath\` and \`line\`: \`- [ ]\` → \`- [x]\`
113
+ - Rerun the apply instructions and confirm that task is now done and progress changed
112
114
  - Continue to next task
113
115
 
114
116
  **Pause if:**
@@ -188,6 +190,7 @@ What would you like to do?
188
190
  - When a task needs work beyond what the spec describes, surface the added scope and pause - never silently narrow, defer, or simplify away specified behavior
189
191
  - Only mark a task \`- [x]\` when its specified behavior is fully implemented, not when it is partially done or deferred
190
192
  - Use contextFiles from CLI output, don't assume specific file names
193
+ - Use each task's sourcePath and line to update its exact checkbox
191
194
  - Do not use context or operation guidance as proof that a task is complete
192
195
  - Apply relevant project context; report conflicts with controlling workflow inputs
193
196
  - Consider every guidance entry; explain any inapplicable or conflicting advice
@@ -140,12 +140,23 @@ ${PROJECT_ROOT_GUARD}
140
140
  form of main specs produced by this merge; do not use them as archive guidance,
141
141
  change CLI behavior, or copy the rule text into any output file.
142
142
 
143
- Then run the \`openspec-sync-specs\` workflow inline (agent-driven intelligent merge) for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
143
+ Then ${optionalWorkflow('sync', 'run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge)', 'perform the delta-to-main-spec merge inline yourself (agent-driven intelligent merge)')} for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
144
+
145
+ If the sync reports any stop or blocking condition, treat the sync as failed.
146
+ Stop the archive immediately. Do not perform the post-sync content comparison and do not move its \`changeRoot\`.
147
+ Nothing has moved, so the user can fix the blocking condition or re-run the sync.
148
+
149
+ After the sync writes each main spec, verify its structure against the canonical sync contract:
150
+ - A new main spec starts with a \`# <capability> Specification\` title. An existing main spec keeps its title exactly as it is.
151
+ - Preserve existing \`## Purpose\` sections completely untouched for established main specs.
152
+ - For a new main spec, copy the delta \`## Purpose\` verbatim. Warn only if the purpose text is shorter than standard validation expects. Do not regenerate or rewrite existing authored purpose. If no usable \`## Purpose\` is provided, use the existing TBD Purpose behavior and warning.
153
+ - Verify that no delta-style section headers (\`## ADDED Requirements\`, \`## MODIFIED Requirements\`, \`## REMOVED Requirements\`, \`## RENAMED Requirements\`) remain in the main spec, adhering strictly to the sync workflow formatting rules.
154
+ - Requirement blocks the sync wrote or changed use \`### Requirement:\` headings, and their scenarios use \`#### Scenario:\` headings, under the spec's \`## Requirements\` section. Leave content the delta does not mention exactly as it is.
144
155
 
145
156
  Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in \`artifactPaths.specs.existingOutputPaths\` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
146
157
  - ADDED requirements present
147
158
  - MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact
148
- - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match
159
+ - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty.
149
160
  - RENAMED requirements present under the new name and absent under the old one
150
161
 
151
162
  If the sync failed, or any capability does not match, report what differs and stop — do not archive. Nothing has moved and \`changeRoot\` is intact, so the user can fix the mismatch or re-run the sync and start the archive again.
@@ -195,7 +206,7 @@ ${PROJECT_ROOT_GUARD}
195
206
  - Don't block archive on warnings - just inform and confirm
196
207
  - Preserve .openspec.yaml when moving to archive (it moves with the directory)
197
208
  - Show clear summary of what happened
198
- - If sync is requested, run the \`openspec-sync-specs\` workflow inline (agent-driven)
209
+ - If sync is requested, ${optionalWorkflow('sync', 'run the `openspec-sync-specs` workflow inline (agent-driven)', 'perform the delta-to-main-spec merge inline (agent-driven)')}
199
210
  - Never archive while a spec sync is still in flight — run the sync inline and verify the main specs before moving \`changeRoot\`
200
211
  - If delta specs exist, always run the sync assessment and show the combined summary before prompting
201
212
  - Apply relevant runtime context and report conflicts; operation guidance remains advisory
@@ -342,10 +353,21 @@ ${PROJECT_ROOT_GUARD}
342
353
 
343
354
  Then ${SYNC_INLINE_HANDOFF} for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
344
355
 
356
+ If the sync reports any stop or blocking condition, treat the sync as failed.
357
+ Stop the archive immediately. Do not perform the post-sync content comparison and do not move its \`changeRoot\`.
358
+ Nothing has moved, so the user can fix the blocking condition or re-run the sync.
359
+
360
+ After the sync writes each main spec, verify its structure against the canonical sync contract:
361
+ - A new main spec starts with a \`# <capability> Specification\` title. An existing main spec keeps its title exactly as it is.
362
+ - Preserve existing \`## Purpose\` sections completely untouched for established main specs.
363
+ - For a new main spec, copy the delta \`## Purpose\` verbatim. Warn only if the purpose text is shorter than standard validation expects. Do not regenerate or rewrite existing authored purpose. If no usable \`## Purpose\` is provided, use the existing TBD Purpose behavior and warning.
364
+ - Verify that no delta-style section headers (\`## ADDED Requirements\`, \`## MODIFIED Requirements\`, \`## REMOVED Requirements\`, \`## RENAMED Requirements\`) remain in the main spec, adhering strictly to the sync workflow formatting rules.
365
+ - Requirement blocks the sync wrote or changed use \`### Requirement:\` headings, and their scenarios use \`#### Scenario:\` headings, under the spec's \`## Requirements\` section. Leave content the delta does not mention exactly as it is.
366
+
345
367
  Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in \`artifactPaths.specs.existingOutputPaths\` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
346
368
  - ADDED requirements present
347
369
  - MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact
348
- - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match
370
+ - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty.
349
371
  - RENAMED requirements present under the new name and absent under the old one
350
372
 
351
373
  If the sync failed, or any capability does not match, report what differs and stop — do not archive. Nothing has moved and \`changeRoot\` is intact, so the user can fix the mismatch or re-run the sync and start the archive again.
@@ -202,6 +202,8 @@ ${PROJECT_ROOT_GUARD}
202
202
 
203
203
  a. **Sync included delta specs**:
204
204
  - ${optionalWorkflow('sync', 'Run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge)', 'Perform the delta-to-main-spec merge inline yourself (agent-driven intelligent merge)')} only for changes with entries in \`includedDeltas\`, passing only the included delta paths and explicitly instructing it to ignore that change's \`excludedDeltas\`. Wait for it to finish.
205
+ - If the sync reports any stop or blocking condition, treat the sync as failed. Stop processing that change immediately. Before continuing to the next change, record this change's outcome as Failed in the batch results, including the sync blocking/error condition.
206
+ - Do not perform the post-sync content comparison and do not move its \`changeRoot\`; leave the change intact.
205
207
  - For conflicts, apply in resolved order.
206
208
  - Pass that change's fetched specs-rule snapshot into inline sync; inline
207
209
  sync must reuse it without fetching instructions again
@@ -216,7 +218,7 @@ ${PROJECT_ROOT_GUARD}
216
218
  - Verify that main specs are updated:
217
219
  - ADDED requirements present
218
220
  - MODIFIED requirements carrying scenario and description changes named in the delta, with their other scenarios intact
219
- - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match
221
+ - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty.
220
222
  - RENAMED requirements present under the new name and absent under the old one
221
223
  - Do not verify delta specs in \`excludedDeltas\`; they are intentionally left unsynced.
222
224
  - If sync failed or any capability does not match verification, report what differs and fail/skip moving that change's \`changeRoot\` — do not archive that change. \`changeRoot\` remains intact.
@@ -567,6 +569,8 @@ ${PROJECT_ROOT_GUARD}
567
569
 
568
570
  a. **Sync included delta specs**:
569
571
  - ${SYNC_INLINE_HANDOFF} only for changes with entries in \`includedDeltas\`, passing only the included delta paths and explicitly instructing it to ignore that change's \`excludedDeltas\`. Wait for it to finish.
572
+ - If the sync reports any stop or blocking condition, treat the sync as failed. Stop processing that change immediately. Before continuing to the next change, record this change's outcome as Failed in the batch results, including the sync blocking/error condition.
573
+ - Do not perform the post-sync content comparison and do not move its \`changeRoot\`; leave the change intact.
570
574
  - For conflicts, apply in resolved order.
571
575
  - Pass that change's fetched specs-rule snapshot into inline sync; inline
572
576
  sync must reuse it without fetching instructions again
@@ -581,7 +585,7 @@ ${PROJECT_ROOT_GUARD}
581
585
  - Verify that main specs are updated:
582
586
  - ADDED requirements present
583
587
  - MODIFIED requirements carrying scenario and description changes named in the delta, with their other scenarios intact
584
- - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match
588
+ - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty.
585
589
  - RENAMED requirements present under the new name and absent under the old one
586
590
  - Do not verify delta specs in \`excludedDeltas\`; they are intentionally left unsynced.
587
591
  - If sync failed or any capability does not match verification, report what differs and fail/skip moving that change's \`changeRoot\` — do not archive that change. \`changeRoot\` remains intact.
@@ -29,7 +29,7 @@ export declare const VALIDATION_MESSAGES: {
29
29
  readonly DELTA_DESCRIPTION_EMPTY: "Delta description cannot be empty";
30
30
  readonly PURPOSE_TOO_BRIEF: "Purpose section is too brief (less than 50 characters)";
31
31
  readonly PURPOSE_IS_PLACEHOLDER: string;
32
- readonly REQUIREMENT_TOO_LONG: "Requirement text is very long (>500 characters). Consider breaking it down.";
32
+ readonly REQUIREMENT_TOO_LONG: "Requirement text is very long (>500 characters). Move examples and edge cases into scenarios, or split it into separate requirements that each state one behavior.";
33
33
  readonly DELTA_DESCRIPTION_TOO_BRIEF: "Delta description is too brief";
34
34
  readonly DELTA_MISSING_REQUIREMENTS: "Delta should include requirements";
35
35
  readonly GUIDE_NO_DELTAS: "No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one \"#### Scenario:\" block. If this change intentionally modifies no specs (pure refactor, tooling, docs), set \"skip_specs: true\" in the change's .openspec.yaml instead. Tip: run \"openspec change show <change-id> --json --deltas-only\" to inspect parsed deltas.";
@@ -43,7 +43,7 @@ export const VALIDATION_MESSAGES = {
43
43
  'writes for a new capability, or a `TBD`/`TODO` marker left in its place). Replace it with what this ' +
44
44
  'capability is for, editing the main spec directly: a `## Purpose` in a delta is read only when the ' +
45
45
  'capability is created, so it cannot replace this one.',
46
- REQUIREMENT_TOO_LONG: `Requirement text is very long (>${MAX_REQUIREMENT_TEXT_LENGTH} characters). Consider breaking it down.`,
46
+ REQUIREMENT_TOO_LONG: `Requirement text is very long (>${MAX_REQUIREMENT_TEXT_LENGTH} characters). Move examples and edge cases into scenarios, or split it into separate requirements that each state one behavior.`,
47
47
  DELTA_DESCRIPTION_TOO_BRIEF: 'Delta description is too brief',
48
48
  DELTA_MISSING_REQUIREMENTS: 'Delta should include requirements',
49
49
  // Guidance snippets (appended to primary messages for remediation)
@@ -10,7 +10,7 @@ import { extractRequirementBody as extractRequirementBodyShared, containsShallOr
10
10
  import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
11
11
  import { FileSystemUtils } from '../../utils/file-system.js';
12
12
  import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
13
- import { METADATA_FILENAME, readSkipSpecsMarker, resolveSchemaForChange, } from '../../utils/change-metadata.js';
13
+ import { METADATA_FILENAME, formatUnknownChangeMetadataKeysMessage, readSkipSpecsMarker, readUnknownChangeMetadataKeys, resolveSchemaForChange, } from '../../utils/change-metadata.js';
14
14
  import { resolveTaskFilesForChange } from '../../utils/task-progress.js';
15
15
  import { findTaskNumberingIssues } from './task-numbering.js';
16
16
  import { findMissingTaskCheckboxIssues } from './task-checkboxes.js';
@@ -416,6 +416,14 @@ export class Validator {
416
416
  if (marker.invalidReason) {
417
417
  issues.push({ level: 'ERROR', path: METADATA_FILENAME, message: this.formatInvalidMarkerMessage(marker.invalidReason) });
418
418
  }
419
+ const unknownMetadataKeys = readUnknownChangeMetadataKeys(changeDir);
420
+ if (unknownMetadataKeys.length > 0) {
421
+ issues.push({
422
+ level: 'WARNING',
423
+ path: METADATA_FILENAME,
424
+ message: formatUnknownChangeMetadataKeysMessage(unknownMetadataKeys),
425
+ });
426
+ }
419
427
  // ANY file under specs/ contradicts the marker - not just parsed deltas.
420
428
  // Headerless or stray files would be silently dropped at archive time (and
421
429
  // some still satisfy the artifact graph's specs/** glob) while the change
@@ -14,9 +14,19 @@ export declare function registryUrl(): string | null;
14
14
  * otherwise. Prereleases sort below their release (1.7.0-beta.1 < 1.7.0).
15
15
  */
16
16
  export declare function compareVersions(a: string, b: string): number;
17
+ export type CliUpdateStatus = 'available' | 'current' | 'disabled' | 'offline';
18
+ export interface CliUpdateCheck {
19
+ status: CliUpdateStatus;
20
+ latest: string | null;
21
+ }
22
+ /**
23
+ * Checks the registry while preserving enough detail for callers to explain
24
+ * why no newer version was reported. Never throws.
25
+ */
26
+ export declare function checkForCliUpdate(): Promise<CliUpdateCheck>;
17
27
  /**
18
28
  * Returns the published version when the installed CLI is behind it, otherwise
19
- * null. Never throws and never blocks for longer than the request timeout.
29
+ * null. Kept as the compatibility surface used by `openspec update`.
20
30
  */
21
31
  export declare function getAvailableCliUpdate(): Promise<string | null>;
22
32
  /**
@@ -69,11 +79,23 @@ export declare function isNpmGlobalInstall(installDir: string | null, roots?: st
69
79
  */
70
80
  export declare function isSourceCheckout(installDir: string | null): boolean;
71
81
  export type PackageManager = 'npm' | 'pnpm' | 'bun' | 'yarn' | 'volta';
82
+ export type InstallScope = 'global' | 'project' | 'temporary' | 'source';
83
+ export interface CliInstallInfo {
84
+ location: string | null;
85
+ packageManager: PackageManager | null;
86
+ scope: InstallScope | null;
87
+ }
72
88
  /**
73
89
  * The package manager that owns this copy, so the printed command is one the
74
90
  * user's setup will actually honor.
75
91
  */
76
92
  export declare function detectPackageManager(installDir: string | null): PackageManager;
93
+ /** Describes the running copy without guessing when its owner is ambiguous. */
94
+ export declare function getCliInstallInfo(installDir?: string | null, projectPath?: string): CliInstallInfo;
95
+ /** Returns a safe update command only for an install with known global ownership. */
96
+ export declare function getCliUpdateCommand(install: CliInstallInfo): string | null;
97
+ /** Builds the concise human-readable form of the version report. */
98
+ export declare function buildVersionReportLines(version: string, install: CliInstallInfo, update?: CliUpdateCheck, command?: string | null): string[];
77
99
  /**
78
100
  * Builds the hint, with the upgrade command chosen for how this copy of the CLI
79
101
  * was installed. Pure so every branch is assertable.
@@ -6,6 +6,7 @@ import { createRequire } from 'module';
6
6
  import chalk from 'chalk';
7
7
  import { isCiEnvironment } from '../utils/ci.js';
8
8
  import { isTelemetryOptedOutByEnv } from '../telemetry/opt-out.js';
9
+ import { FileSystemUtils } from '../utils/file-system.js';
9
10
  import { getGlobalConfig, isGlobalConfigUnreadable } from './global-config.js';
10
11
  const require = createRequire(import.meta.url);
11
12
  const { name: PACKAGE_NAME, version: OPENSPEC_VERSION } = require('../../package.json');
@@ -268,22 +269,34 @@ function fetchLatestVersion() {
268
269
  });
269
270
  }
270
271
  /**
271
- * Returns the published version when the installed CLI is behind it, otherwise
272
- * null. Never throws and never blocks for longer than the request timeout.
272
+ * Checks the registry while preserving enough detail for callers to explain
273
+ * why no newer version was reported. Never throws.
273
274
  */
274
- export async function getAvailableCliUpdate() {
275
- if (!isCheckEnabled())
276
- return null;
275
+ export async function checkForCliUpdate() {
276
+ if (!isCheckEnabled() || registryUrl() === null) {
277
+ return { status: 'disabled', latest: null };
278
+ }
277
279
  try {
278
280
  const latest = await fetchLatestVersion();
279
281
  if (!latest)
280
- return null;
281
- return compareVersions(latest, OPENSPEC_VERSION) > 0 ? latest : null;
282
+ return { status: 'offline', latest: null };
283
+ return {
284
+ status: compareVersions(latest, OPENSPEC_VERSION) > 0 ? 'available' : 'current',
285
+ latest,
286
+ };
282
287
  }
283
288
  catch {
284
- return null;
289
+ return { status: 'offline', latest: null };
285
290
  }
286
291
  }
292
+ /**
293
+ * Returns the published version when the installed CLI is behind it, otherwise
294
+ * null. Kept as the compatibility surface used by `openspec update`.
295
+ */
296
+ export async function getAvailableCliUpdate() {
297
+ const result = await checkForCliUpdate();
298
+ return result.status === 'available' ? result.latest : null;
299
+ }
287
300
  /**
288
301
  * Directory the running CLI was loaded from, or null when it cannot be
289
302
  * resolved. Shown in the upgrade hint so anyone who upgraded but still runs an
@@ -312,8 +325,8 @@ export function isProjectLocalInstall(installDir, projectPath = '.') {
312
325
  // Windows paths differ in case and drive-letter casing between sources.
313
326
  const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
314
327
  try {
315
- let dir = path.resolve(projectPath);
316
- const target = normalize(installDir);
328
+ let dir = FileSystemUtils.canonicalizeExistingPath(projectPath);
329
+ const target = normalize(FileSystemUtils.canonicalizeExistingPath(installDir));
317
330
  for (;;) {
318
331
  if (target.startsWith(normalize(path.join(dir, 'node_modules') + path.sep))) {
319
332
  return true;
@@ -443,32 +456,29 @@ export function isSourceCheckout(installDir) {
443
456
  return false;
444
457
  }
445
458
  }
446
- /**
447
- * The package manager that owns this copy, so the printed command is one the
448
- * user's setup will actually honor.
449
- */
450
- export function detectPackageManager(installDir) {
451
- // Lowercased because the Windows directories are capitalized and undotted:
452
- // %LOCALAPPDATA%\\Volta, \\Yarn\\Data, \\pnpm-cache.
459
+ function detectKnownPackageManager(installDir) {
453
460
  const segments = (installDir ?? '').split(/[\\/]/).map((segment) => segment.toLowerCase());
454
461
  const has = (...names) => names.some((name) => segments.includes(name));
455
- // The undotted spelling exists for Windows (%LOCALAPPDATA%\Volta), whose
456
- // layout nests tools\image; require both segments so a user or project
457
- // directory merely named "volta" (even one with its own "tools" dir) does
458
- // not steal the install.
459
462
  if (has('.volta') || (has('volta') && has('tools') && has('image')))
460
463
  return 'volta';
461
- if (has('.bun'))
464
+ if (has('.bun', '_bunx', 'bun-cache'))
462
465
  return 'bun';
463
- // These two need a corroborating segment: a directory merely named "pnpm" or
464
- // "yarn" (a user's home, a project) is not a global install of one.
465
- if (has('.pnpm-global', 'pnpm-cache'))
466
+ if (has('_npx'))
467
+ return 'npm';
468
+ if (has('.pnpm', '.pnpm-global', 'pnpm-cache'))
466
469
  return 'pnpm';
467
470
  if (has('pnpm') && has('global', 'dlx', 'store'))
468
471
  return 'pnpm';
469
472
  if (has('.yarn') || (has('yarn') && has('global')))
470
473
  return 'yarn';
471
- return 'npm';
474
+ return null;
475
+ }
476
+ /**
477
+ * The package manager that owns this copy, so the printed command is one the
478
+ * user's setup will actually honor.
479
+ */
480
+ export function detectPackageManager(installDir) {
481
+ return detectKnownPackageManager(installDir) ?? 'npm';
472
482
  }
473
483
  const GLOBAL_UPGRADE_COMMANDS = {
474
484
  npm: `npm install -g ${PACKAGE_NAME}@latest`,
@@ -477,6 +487,82 @@ const GLOBAL_UPGRADE_COMMANDS = {
477
487
  yarn: `yarn global add ${PACKAGE_NAME}@latest`,
478
488
  volta: `volta install ${PACKAGE_NAME}@latest`,
479
489
  };
490
+ function detectGlobalPackageManager(installDir) {
491
+ if (isNpmGlobalInstall(installDir))
492
+ return 'npm';
493
+ if (!installDir)
494
+ return null;
495
+ const segments = installDir.split(/[\\/]/).map((segment) => segment.toLowerCase());
496
+ const has = (...names) => names.some((name) => segments.includes(name));
497
+ const hasSequence = (...names) => segments.some((_, index) => names.every((name, offset) => segments[index + offset] === name));
498
+ if (hasSequence('.volta', 'tools', 'image') || hasSequence('volta', 'tools', 'image')) {
499
+ return 'volta';
500
+ }
501
+ if (has('.pnpm-global') || hasSequence('pnpm', 'global'))
502
+ return 'pnpm';
503
+ if (hasSequence('yarn', 'global') || hasSequence('yarn', 'data', 'global'))
504
+ return 'yarn';
505
+ if (hasSequence('.bun', 'install', 'global'))
506
+ return 'bun';
507
+ return null;
508
+ }
509
+ /** Describes the running copy without guessing when its owner is ambiguous. */
510
+ export function getCliInstallInfo(installDir = getInstallDir(), projectPath = '.') {
511
+ if (!installDir) {
512
+ return { location: null, packageManager: null, scope: null };
513
+ }
514
+ if (isSourceCheckout(installDir)) {
515
+ return { location: installDir, packageManager: null, scope: 'source' };
516
+ }
517
+ if (isEphemeralRunnerInstall(installDir)) {
518
+ return {
519
+ location: installDir,
520
+ packageManager: detectKnownPackageManager(installDir),
521
+ scope: 'temporary',
522
+ };
523
+ }
524
+ if (isProjectLocalInstall(installDir, projectPath)) {
525
+ return {
526
+ location: installDir,
527
+ packageManager: detectKnownPackageManager(installDir),
528
+ scope: 'project',
529
+ };
530
+ }
531
+ const packageManager = detectGlobalPackageManager(installDir);
532
+ return {
533
+ location: installDir,
534
+ packageManager,
535
+ scope: packageManager ? 'global' : null,
536
+ };
537
+ }
538
+ /** Returns a safe update command only for an install with known global ownership. */
539
+ export function getCliUpdateCommand(install) {
540
+ if (install.scope !== 'global' || install.packageManager === null)
541
+ return null;
542
+ return GLOBAL_UPGRADE_COMMANDS[install.packageManager];
543
+ }
544
+ /** Builds the concise human-readable form of the version report. */
545
+ export function buildVersionReportLines(version, install, update, command = null) {
546
+ const details = [install.packageManager, install.scope].filter(Boolean).join(', ');
547
+ const lines = [`OpenSpec ${version}${details ? ` (${details})` : ''}`];
548
+ if (!update)
549
+ return lines;
550
+ if (update.status === 'available') {
551
+ lines.push(`Update available: ${update.latest}`);
552
+ if (command)
553
+ lines.push(` ${command}`);
554
+ }
555
+ else if (update.status === 'current') {
556
+ lines.push('OpenSpec is up to date.');
557
+ }
558
+ else if (update.status === 'disabled') {
559
+ lines.push('Update check disabled.');
560
+ }
561
+ else {
562
+ lines.push('Could not check for updates.');
563
+ }
564
+ return lines;
565
+ }
480
566
  /**
481
567
  * Builds the hint, with the upgrade command chosen for how this copy of the CLI
482
568
  * was installed. Pure so every branch is assertable.