@fission-ai/openspec 1.6.0-beta.1 → 1.7.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 (175) hide show
  1. package/README.md +28 -1
  2. package/dist/cli/index.js +73 -7
  3. package/dist/commands/change.d.ts +0 -1
  4. package/dist/commands/change.js +90 -50
  5. package/dist/commands/config.js +14 -8
  6. package/dist/commands/doctor.js +9 -4
  7. package/dist/commands/feedback.js +74 -23
  8. package/dist/commands/schema.js +21 -11
  9. package/dist/commands/show.js +5 -2
  10. package/dist/commands/spec.js +20 -24
  11. package/dist/commands/validate.js +18 -3
  12. package/dist/commands/workflow/index.d.ts +1 -1
  13. package/dist/commands/workflow/index.js +1 -1
  14. package/dist/commands/workflow/instructions.d.ts +7 -1
  15. package/dist/commands/workflow/instructions.js +89 -6
  16. package/dist/commands/workflow/shared.d.ts +13 -2
  17. package/dist/commands/workflow/shared.js +34 -4
  18. package/dist/commands/workflow/status.js +7 -2
  19. package/dist/core/archive.d.ts +0 -1
  20. package/dist/core/archive.js +96 -33
  21. package/dist/core/artifact-graph/graph.d.ts +16 -0
  22. package/dist/core/artifact-graph/graph.js +30 -6
  23. package/dist/core/artifact-graph/instruction-loader.d.ts +27 -2
  24. package/dist/core/artifact-graph/instruction-loader.js +55 -7
  25. package/dist/core/artifact-graph/resolver.d.ts +14 -0
  26. package/dist/core/artifact-graph/resolver.js +34 -6
  27. package/dist/core/change-metadata/schema.d.ts +1 -0
  28. package/dist/core/change-metadata/schema.js +6 -0
  29. package/dist/core/change-status-policy.d.ts +1 -1
  30. package/dist/core/command-generation/adapters/amazon-q.d.ts +4 -0
  31. package/dist/core/command-generation/adapters/amazon-q.js +7 -1
  32. package/dist/core/command-generation/adapters/antigravity.js +2 -1
  33. package/dist/core/command-generation/adapters/auggie.js +2 -1
  34. package/dist/core/command-generation/adapters/bob.d.ts +5 -1
  35. package/dist/core/command-generation/adapters/bob.js +6 -5
  36. package/dist/core/command-generation/adapters/claude.js +1 -8
  37. package/dist/core/command-generation/adapters/codebuddy.js +3 -2
  38. package/dist/core/command-generation/adapters/continue.js +3 -2
  39. package/dist/core/command-generation/adapters/costrict.js +2 -1
  40. package/dist/core/command-generation/adapters/crush.js +5 -5
  41. package/dist/core/command-generation/adapters/cursor.js +2 -2
  42. package/dist/core/command-generation/adapters/devin.d.ts +19 -0
  43. package/dist/core/command-generation/adapters/devin.js +36 -0
  44. package/dist/core/command-generation/adapters/factory.js +2 -1
  45. package/dist/core/command-generation/adapters/gemini.js +37 -2
  46. package/dist/core/command-generation/adapters/github-copilot.js +2 -1
  47. package/dist/core/command-generation/adapters/iflow.js +5 -4
  48. package/dist/core/command-generation/adapters/index.d.ts +2 -2
  49. package/dist/core/command-generation/adapters/index.js +2 -2
  50. package/dist/core/command-generation/adapters/junie.js +2 -1
  51. package/dist/core/command-generation/adapters/kiro.js +2 -1
  52. package/dist/core/command-generation/adapters/lingma.js +5 -5
  53. package/dist/core/command-generation/adapters/oh-my-pi.d.ts +2 -2
  54. package/dist/core/command-generation/adapters/oh-my-pi.js +3 -5
  55. package/dist/core/command-generation/adapters/opencode.js +3 -5
  56. package/dist/core/command-generation/adapters/pi.d.ts +2 -2
  57. package/dist/core/command-generation/adapters/pi.js +3 -6
  58. package/dist/core/command-generation/adapters/qoder.js +5 -5
  59. package/dist/core/command-generation/adapters/qwen.d.ts +7 -3
  60. package/dist/core/command-generation/adapters/qwen.js +12 -7
  61. package/dist/core/command-generation/adapters/roocode.d.ts +4 -4
  62. package/dist/core/command-generation/adapters/roocode.js +4 -4
  63. package/dist/core/command-generation/adapters/trae.js +1 -21
  64. package/dist/core/command-generation/adapters/zcode.d.ts +16 -0
  65. package/dist/core/command-generation/adapters/zcode.js +33 -0
  66. package/dist/core/command-generation/generator.d.ts +8 -0
  67. package/dist/core/command-generation/generator.js +15 -1
  68. package/dist/core/command-generation/index.d.ts +1 -1
  69. package/dist/core/command-generation/index.js +1 -1
  70. package/dist/core/command-generation/invocation.d.ts +73 -0
  71. package/dist/core/command-generation/invocation.js +77 -0
  72. package/dist/core/command-generation/registry.js +4 -4
  73. package/dist/core/command-generation/types.d.ts +9 -2
  74. package/dist/core/command-generation/yaml.d.ts +20 -6
  75. package/dist/core/command-generation/yaml.js +31 -21
  76. package/dist/core/command-surface.d.ts +15 -0
  77. package/dist/core/command-surface.js +33 -0
  78. package/dist/core/completions/command-registry.js +8 -2
  79. package/dist/core/completions/generators/powershell-generator.js +15 -8
  80. package/dist/core/completions/installers/zsh-installer.d.ts +9 -0
  81. package/dist/core/completions/installers/zsh-installer.js +20 -4
  82. package/dist/core/config-prompts.d.ts +1 -1
  83. package/dist/core/config-prompts.js +14 -1
  84. package/dist/core/config-schema.d.ts +7 -1
  85. package/dist/core/config-schema.js +40 -1
  86. package/dist/core/config.d.ts +12 -0
  87. package/dist/core/config.js +21 -3
  88. package/dist/core/global-config.d.ts +5 -0
  89. package/dist/core/init.d.ts +35 -0
  90. package/dist/core/init.js +225 -39
  91. package/dist/core/legacy-cleanup.d.ts +59 -0
  92. package/dist/core/legacy-cleanup.js +249 -22
  93. package/dist/core/list.js +5 -7
  94. package/dist/core/migration.d.ts +71 -1
  95. package/dist/core/migration.js +294 -1
  96. package/dist/core/onboarding-commands.d.ts +30 -0
  97. package/dist/core/onboarding-commands.js +38 -0
  98. package/dist/core/parsers/change-parser.d.ts +17 -2
  99. package/dist/core/parsers/change-parser.js +33 -20
  100. package/dist/core/parsers/code-fence.d.ts +15 -0
  101. package/dist/core/parsers/code-fence.js +49 -0
  102. package/dist/core/parsers/markdown-parser.js +2 -1
  103. package/dist/core/parsers/requirement-blocks.d.ts +8 -0
  104. package/dist/core/parsers/requirement-blocks.js +60 -35
  105. package/dist/core/parsers/requirement-text.d.ts +1 -8
  106. package/dist/core/parsers/requirement-text.js +4 -42
  107. package/dist/core/parsers/spec-structure.js +3 -29
  108. package/dist/core/profile-sync-drift.d.ts +0 -8
  109. package/dist/core/profile-sync-drift.js +9 -45
  110. package/dist/core/project-config.d.ts +25 -6
  111. package/dist/core/project-config.js +77 -7
  112. package/dist/core/references.js +42 -4
  113. package/dist/core/relationship-health.d.ts +8 -0
  114. package/dist/core/relationship-health.js +13 -0
  115. package/dist/core/root-selection.d.ts +9 -6
  116. package/dist/core/root-selection.js +42 -5
  117. package/dist/core/shared/tool-detection.d.ts +25 -5
  118. package/dist/core/shared/tool-detection.js +105 -6
  119. package/dist/core/specs-apply.d.ts +8 -33
  120. package/dist/core/specs-apply.js +252 -157
  121. package/dist/core/store/git.d.ts +13 -0
  122. package/dist/core/store/git.js +24 -0
  123. package/dist/core/templates/workflows/apply-change.js +54 -4
  124. package/dist/core/templates/workflows/archive-change.js +166 -42
  125. package/dist/core/templates/workflows/bulk-archive-change.js +240 -63
  126. package/dist/core/templates/workflows/continue-change.js +32 -40
  127. package/dist/core/templates/workflows/explore.js +12 -0
  128. package/dist/core/templates/workflows/ff-change.js +46 -30
  129. package/dist/core/templates/workflows/new-change.js +2 -2
  130. package/dist/core/templates/workflows/onboard.js +4 -4
  131. package/dist/core/templates/workflows/propose.js +50 -32
  132. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  133. package/dist/core/templates/workflows/store-selection.js +1 -1
  134. package/dist/core/templates/workflows/sync-specs.js +172 -22
  135. package/dist/core/templates/workflows/update-change.js +20 -12
  136. package/dist/core/templates/workflows/verify-change.js +16 -10
  137. package/dist/core/update.d.ts +39 -5
  138. package/dist/core/update.js +288 -71
  139. package/dist/core/validation/constants.d.ts +4 -1
  140. package/dist/core/validation/constants.js +4 -1
  141. package/dist/core/validation/validator.d.ts +1 -7
  142. package/dist/core/validation/validator.js +92 -36
  143. package/dist/core/version-check.d.ts +152 -0
  144. package/dist/core/version-check.js +736 -0
  145. package/dist/core/view.js +12 -17
  146. package/dist/prompts/searchable-multi-select.js +1 -1
  147. package/dist/telemetry/index.d.ts +1 -1
  148. package/dist/telemetry/index.js +55 -33
  149. package/dist/ui/welcome-screen.d.ts +9 -1
  150. package/dist/ui/welcome-screen.js +91 -38
  151. package/dist/utils/change-metadata.d.ts +30 -0
  152. package/dist/utils/change-metadata.js +101 -7
  153. package/dist/utils/change-utils.d.ts +10 -4
  154. package/dist/utils/change-utils.js +20 -13
  155. package/dist/utils/command-references.d.ts +80 -6
  156. package/dist/utils/command-references.js +138 -7
  157. package/dist/utils/date.d.ts +8 -0
  158. package/dist/utils/date.js +13 -0
  159. package/dist/utils/index.d.ts +1 -1
  160. package/dist/utils/index.js +1 -1
  161. package/dist/utils/item-discovery.d.ts +19 -0
  162. package/dist/utils/item-discovery.js +30 -48
  163. package/dist/utils/shell-detection.d.ts +1 -1
  164. package/dist/utils/shell-detection.js +64 -11
  165. package/dist/utils/spec-discovery.d.ts +40 -0
  166. package/dist/utils/spec-discovery.js +102 -0
  167. package/package.json +29 -30
  168. package/schemas/spec-driven/schema.yaml +60 -5
  169. package/schemas/spec-driven/templates/design.md +2 -2
  170. package/schemas/spec-driven/templates/proposal.md +5 -1
  171. package/schemas/spec-driven/templates/spec.md +3 -0
  172. package/dist/core/command-generation/adapters/codex.d.ts +0 -16
  173. package/dist/core/command-generation/adapters/codex.js +0 -39
  174. package/dist/core/command-generation/adapters/windsurf.d.ts +0 -14
  175. package/dist/core/command-generation/adapters/windsurf.js +0 -38
@@ -1,16 +1,25 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
+ import { formatLocalDate } from '../utils/date.js';
3
4
  import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
4
5
  import { Validator } from './validation/validator.js';
5
6
  import chalk from 'chalk';
6
7
  import { emitStoreRootBanner, isRootSelectionError, resolveOpenSpecRoot, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from './root-selection.js';
7
8
  import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, } from './specs-apply.js';
9
+ import { discoverSpecFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
10
+ import { readSkipSpecsMarker } from '../utils/change-metadata.js';
8
11
  function isMissingPathError(error) {
9
12
  return (typeof error === 'object' &&
10
13
  error !== null &&
11
14
  'code' in error &&
12
15
  error.code === 'ENOENT');
13
16
  }
17
+ /**
18
+ * Matches the `YYYY-MM-DD-` prefix that archiving prepends to a change name.
19
+ * A change whose name already starts with one (a common authoring convention)
20
+ * is archived under its existing name so the prefix is never stacked (#1309).
21
+ */
22
+ const ARCHIVE_DATE_PREFIX_PATTERN = /^\d{4}-\d{2}-\d{2}-/;
14
23
  async function listActiveChangeNames(changesDir) {
15
24
  try {
16
25
  const entries = await fs.readdir(changesDir, { withFileTypes: true });
@@ -181,10 +190,21 @@ export class ArchiveCommand {
181
190
  try {
182
191
  await fs.access(changeFile);
183
192
  const changeReport = await validator.validateChange(changeFile);
184
- // Proposal validation is informative only (do not block archive)
185
- if (!changeReport.valid) {
193
+ // Proposal validation is informative only (do not block archive).
194
+ // `validateChange` parses the change together with its delta specs,
195
+ // so it also raises requirement-level issues under
196
+ // `deltas.<n>.requirement(s)`. Those
197
+ // are not proposal problems, and reporting them here was noisy and
198
+ // sometimes wrong (#498): the change parser records every requirement
199
+ // under both `requirement` and `requirements`, so each defect was
200
+ // printed twice, and REMOVED requirements — names-only by design —
201
+ // produced a "missing scenario" warning for a correct removal.
202
+ // Genuine delta defects are still caught below, by the delta spec
203
+ // validation and by the rebuilt-spec check that runs before any write.
204
+ const proposalIssues = changeReport.issues.filter((issue) => !/^deltas\.\d+\.requirements?\./.test(issue.path));
205
+ if (!changeReport.valid && proposalIssues.length > 0) {
186
206
  console.log(chalk.yellow(`\nProposal warnings in proposal.md (non-blocking):`));
187
- for (const issue of changeReport.issues) {
207
+ for (const issue of proposalIssues) {
188
208
  const symbol = issue.level === 'ERROR' ? '⚠' : (issue.level === 'WARNING' ? '⚠' : 'ℹ');
189
209
  console.log(chalk.yellow(` ${symbol} ${issue.message}`));
190
210
  }
@@ -196,25 +216,52 @@ export class ArchiveCommand {
196
216
  }
197
217
  // Validate delta-formatted spec files under the change directory if present
198
218
  const changeSpecsDir = path.join(changeDir, 'specs');
199
- let hasDeltaSpecs = false;
200
- try {
201
- const candidates = await fs.readdir(changeSpecsDir, { withFileTypes: true });
202
- for (const c of candidates) {
203
- if (c.isDirectory()) {
204
- try {
205
- const candidatePath = path.join(changeSpecsDir, c.name, 'spec.md');
206
- await fs.access(candidatePath);
207
- const content = await fs.readFile(candidatePath, 'utf-8');
208
- if (/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements/m.test(content)) {
209
- hasDeltaSpecs = true;
210
- break;
211
- }
212
- }
213
- catch { }
219
+ // A spec.md at the specs/ root is never merged, so archiving a change
220
+ // that has one drops its content whether or not it carries delta headers
221
+ // (#1385). Its existence alone must run validation, which reports it and
222
+ // blocks the archive. A directory named spec.md is a normal capability
223
+ // folder, so only a regular file counts.
224
+ const rootSpecStat = await fs.stat(path.join(changeSpecsDir, 'spec.md')).catch(() => null);
225
+ let hasDeltaSpecs = rootSpecStat?.isFile() === true;
226
+ // A change that declares skip_specs must not carry any file under
227
+ // specs/ — validate reports that as a conflict, so archive has to run
228
+ // the same check instead of skipping validation because the files
229
+ // happen to have no delta headers. A marker that cannot be honored
230
+ // (skip_specs mentioned but the metadata fails the shared shape, or
231
+ // names a schema that does not resolve) also
232
+ // forces validation, so archive and validate always agree about the
233
+ // marker. Unreadable specs/ fails closed into validation too. (An
234
+ // UNMARKED zero-delta change still archives with only non-blocking
235
+ // proposal warnings — a gap that predates the marker and is left
236
+ // unchanged here.)
237
+ if (!hasDeltaSpecs) {
238
+ const marker = readSkipSpecsMarker(changeDir);
239
+ if (marker.invalidReason) {
240
+ hasDeltaSpecs = true;
241
+ }
242
+ else if (marker.declared) {
243
+ let specsDirHasFiles = true;
244
+ try {
245
+ specsDirHasFiles = await hasAnyFileUnder(changeSpecsDir);
214
246
  }
247
+ catch {
248
+ // fall through with true: let validation surface the conflict
249
+ }
250
+ hasDeltaSpecs = specsDirHasFiles;
215
251
  }
216
252
  }
217
- catch { }
253
+ for (const { specFile } of hasDeltaSpecs ? [] : await discoverSpecFiles(changeSpecsDir)) {
254
+ try {
255
+ const content = await fs.readFile(specFile, 'utf-8');
256
+ // Case-insensitive to match the delta parser, so a lowercase header
257
+ // routes through the same delta validation that validate runs.
258
+ if (/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements/im.test(content)) {
259
+ hasDeltaSpecs = true;
260
+ break;
261
+ }
262
+ }
263
+ catch { }
264
+ }
218
265
  if (hasDeltaSpecs) {
219
266
  const deltaReport = await validator.validateChangeDeltaSpecs(changeDir);
220
267
  if (!deltaReport.valid) {
@@ -298,6 +345,7 @@ export class ArchiveCommand {
298
345
  // Handle spec updates unless skipSpecs flag is set
299
346
  let specsUpdated = false;
300
347
  let totals;
348
+ const specWarnings = [];
301
349
  if (options.skipSpecs) {
302
350
  if (!json) {
303
351
  console.log('Skipping spec updates (--skip-specs flag provided).');
@@ -311,7 +359,7 @@ export class ArchiveCommand {
311
359
  console.log('\nSpecs to update:');
312
360
  for (const update of specUpdates) {
313
361
  const status = update.exists ? 'update' : 'create';
314
- const capability = path.basename(path.dirname(update.target));
362
+ const capability = update.id;
315
363
  console.log(` ${capability}: ${status}`);
316
364
  }
317
365
  }
@@ -336,6 +384,9 @@ export class ArchiveCommand {
336
384
  for (const update of specUpdates) {
337
385
  const built = await buildUpdatedSpec(update, changeName, { silent: json });
338
386
  prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts });
387
+ // Carried into the result so JSON mode (where nothing was
388
+ // printed) still surfaces them; human mode discards the result.
389
+ specWarnings.push(...built.warnings);
339
390
  }
340
391
  }
341
392
  catch (err) {
@@ -351,7 +402,7 @@ export class ArchiveCommand {
351
402
  // late validation failure really does leave all targets unchanged.
352
403
  if (!skipValidation) {
353
404
  for (const p of prepared) {
354
- const specName = path.basename(path.dirname(p.update.target));
405
+ const specName = p.update.id;
355
406
  const report = await new Validator().validateSpecContent(specName, p.rebuilt);
356
407
  if (!report.valid) {
357
408
  if (json) {
@@ -372,28 +423,43 @@ export class ArchiveCommand {
372
423
  }
373
424
  // All validations passed; write files and display counts
374
425
  const writeTotals = { added: 0, modified: 0, removed: 0, renamed: 0 };
426
+ let wroteAny = false;
375
427
  for (const p of prepared) {
428
+ const { added, modified, removed, renamed } = p.counts;
429
+ if (added + modified + removed + renamed === 0) {
430
+ // Every operation was already synced: rewriting the file would
431
+ // only churn normalization differences into it.
432
+ continue;
433
+ }
376
434
  await writeUpdatedSpec(p.update, p.rebuilt, p.counts, {
377
435
  silent: json,
378
436
  // Cross-root paths must be absolute when a store is selected.
379
437
  ...(isStoreSelectedRoot(root) ? { displayPath: p.update.target } : {}),
380
438
  });
381
- writeTotals.added += p.counts.added;
382
- writeTotals.modified += p.counts.modified;
383
- writeTotals.removed += p.counts.removed;
384
- writeTotals.renamed += p.counts.renamed;
439
+ wroteAny = true;
440
+ writeTotals.added += added;
441
+ writeTotals.modified += modified;
442
+ writeTotals.removed += removed;
443
+ writeTotals.renamed += renamed;
385
444
  }
386
- specsUpdated = true;
445
+ specsUpdated = wroteAny;
387
446
  totals = writeTotals;
388
447
  if (!json) {
389
448
  console.log(`Totals: + ${writeTotals.added}, ~ ${writeTotals.modified}, - ${writeTotals.removed}, → ${writeTotals.renamed}`);
390
- console.log('Specs updated successfully.');
449
+ console.log(wroteAny
450
+ ? 'Specs updated successfully.'
451
+ : 'Specs already in sync; no files changed.');
391
452
  }
392
453
  }
393
454
  }
394
455
  }
395
- // Create archive directory with date prefix
396
- const archiveName = `${this.getArchiveDate()}-${changeName}`;
456
+ // Create archive directory with date prefix. Names that already carry
457
+ // one keep it: re-prefixing would stutter the name, and when the archive
458
+ // runs on a later day the folder would sort under a day on which the
459
+ // change did not happen (#1309).
460
+ const archiveName = ARCHIVE_DATE_PREFIX_PATTERN.test(changeName)
461
+ ? changeName
462
+ : `${formatLocalDate()}-${changeName}`;
397
463
  const archivePath = path.join(archiveDir, archiveName);
398
464
  // Check if archive already exists
399
465
  let archiveExists = false;
@@ -422,6 +488,7 @@ export class ArchiveCommand {
422
488
  path: archivePath,
423
489
  specsUpdated,
424
490
  ...(totals ? { totals } : {}),
491
+ ...(specWarnings.length > 0 ? { warnings: specWarnings } : {}),
425
492
  };
426
493
  }
427
494
  async selectChange(changesDir) {
@@ -462,9 +529,5 @@ export class ArchiveCommand {
462
529
  return null;
463
530
  }
464
531
  }
465
- getArchiveDate() {
466
- // Returns date in YYYY-MM-DD format
467
- return new Date().toISOString().split('T')[0];
468
- }
469
532
  }
470
533
  //# sourceMappingURL=archive.js.map
@@ -6,7 +6,23 @@ import type { Artifact, SchemaYaml, CompletedSet, BlockedArtifacts } from './typ
6
6
  export declare class ArtifactGraph {
7
7
  private artifacts;
8
8
  private schema;
9
+ /** Artifact id -> its position in the schema's `artifacts:` list. */
10
+ private declarationOrder;
9
11
  private constructor();
12
+ /**
13
+ * Orders artifact ids by where the schema declares them.
14
+ *
15
+ * The dependency graph leaves siblings tied - spec-driven's `specs` and
16
+ * `design` both require only `proposal`, so both become ready at the same
17
+ * time. Ties used to be broken alphabetically, which put `design` ahead of
18
+ * `specs` and made the CLI recommend the artifacts in an order that
19
+ * contradicted the schema's own documented sequence
20
+ * (proposal -> specs -> design -> tasks). Breaking ties by declaration order
21
+ * follows the sequence the schema author wrote, for built-in and custom
22
+ * schemas alike, and stays just as deterministic. Ids not in the schema sort
23
+ * last so the comparator stays total.
24
+ */
25
+ private compareByDeclarationOrder;
10
26
  /**
11
27
  * Creates an ArtifactGraph from a YAML file path.
12
28
  */
@@ -6,9 +6,29 @@ import { loadSchema, parseSchema } from './schema.js';
6
6
  export class ArtifactGraph {
7
7
  artifacts;
8
8
  schema;
9
+ /** Artifact id -> its position in the schema's `artifacts:` list. */
10
+ declarationOrder;
9
11
  constructor(schema) {
10
12
  this.schema = schema;
11
13
  this.artifacts = new Map(schema.artifacts.map(a => [a.id, a]));
14
+ this.declarationOrder = new Map(schema.artifacts.map((a, index) => [a.id, index]));
15
+ }
16
+ /**
17
+ * Orders artifact ids by where the schema declares them.
18
+ *
19
+ * The dependency graph leaves siblings tied - spec-driven's `specs` and
20
+ * `design` both require only `proposal`, so both become ready at the same
21
+ * time. Ties used to be broken alphabetically, which put `design` ahead of
22
+ * `specs` and made the CLI recommend the artifacts in an order that
23
+ * contradicted the schema's own documented sequence
24
+ * (proposal -> specs -> design -> tasks). Breaking ties by declaration order
25
+ * follows the sequence the schema author wrote, for built-in and custom
26
+ * schemas alike, and stays just as deterministic. Ids not in the schema sort
27
+ * last so the comparator stays total.
28
+ */
29
+ compareByDeclarationOrder(a, b) {
30
+ return ((this.declarationOrder.get(a) ?? Number.MAX_SAFE_INTEGER) -
31
+ (this.declarationOrder.get(b) ?? Number.MAX_SAFE_INTEGER));
12
32
  }
13
33
  /**
14
34
  * Creates an ArtifactGraph from a YAML file path.
@@ -72,10 +92,10 @@ export class ArtifactGraph {
72
92
  dependents.get(req).push(artifact.id);
73
93
  }
74
94
  }
75
- // Start with roots (in-degree 0), sorted for determinism
95
+ // Start with roots (in-degree 0), in declaration order for determinism
76
96
  const queue = [...this.artifacts.keys()]
77
97
  .filter(id => inDegree.get(id) === 0)
78
- .sort();
98
+ .sort((a, b) => this.compareByDeclarationOrder(a, b));
79
99
  const result = [];
80
100
  while (queue.length > 0) {
81
101
  const current = queue.shift();
@@ -89,7 +109,10 @@ export class ArtifactGraph {
89
109
  newlyReady.push(dep);
90
110
  }
91
111
  }
92
- queue.push(...newlyReady.sort());
112
+ // Re-sort the whole queue, not just the new arrivals: an artifact that
113
+ // has been waiting can be declared after one that just became ready.
114
+ queue.push(...newlyReady);
115
+ queue.sort((a, b) => this.compareByDeclarationOrder(a, b));
93
116
  }
94
117
  return result;
95
118
  }
@@ -107,8 +130,9 @@ export class ArtifactGraph {
107
130
  ready.push(artifact.id);
108
131
  }
109
132
  }
110
- // Sort for deterministic ordering
111
- return ready.sort();
133
+ // Declaration order: deterministic, and the first entry is the artifact the
134
+ // schema wants written next.
135
+ return ready.sort((a, b) => this.compareByDeclarationOrder(a, b));
112
136
  }
113
137
  /**
114
138
  * Checks if all artifacts in the graph are completed.
@@ -132,7 +156,7 @@ export class ArtifactGraph {
132
156
  }
133
157
  const unmetDeps = artifact.requires.filter(req => !completed.has(req));
134
158
  if (unmetDeps.length > 0) {
135
- blocked[artifact.id] = unmetDeps.sort();
159
+ blocked[artifact.id] = unmetDeps.sort((a, b) => this.compareByDeclarationOrder(a, b));
136
160
  }
137
161
  }
138
162
  return blocked;
@@ -32,10 +32,18 @@ export interface ChangeContext {
32
32
  planningHome?: PlanningHome;
33
33
  /** Parsed change metadata, when present */
34
34
  metadata?: ChangeMetadata;
35
+ /**
36
+ * Artifact IDs counted as complete only because the change declares
37
+ * skip_specs, not because their files exist. Kept separate so status can
38
+ * render them as skipped rather than done.
39
+ */
40
+ skippedArtifacts?: Set<string>;
35
41
  }
36
42
  export interface LoadChangeContextOptions {
37
43
  changeDir?: string;
38
44
  planningHome?: PlanningHome;
45
+ /** Pre-read project config; suppresses schema resolution's fallback config read. */
46
+ projectConfig?: ProjectConfig | null;
39
47
  }
40
48
  /**
41
49
  * Enriched instructions for creating an artifact.
@@ -73,7 +81,17 @@ export interface ArtifactInstructions {
73
81
  dependencies: DependencyInfo[];
74
82
  /** Artifacts that become available after completing this one */
75
83
  unlocks: string[];
84
+ /** True when the change declares skip_specs and this artifact is skipped */
85
+ skipped?: boolean;
86
+ /** Present only when skipped: tells the consumer not to create the artifact */
87
+ warning?: string;
76
88
  }
89
+ /**
90
+ * Warning attached to instructions for an artifact skipped via skip_specs.
91
+ * Carried in the JSON payload too, so agents driving the CLI with --json see
92
+ * the same do-not-create signal as the text output.
93
+ */
94
+ export declare const SKIP_SPECS_INSTRUCTIONS_WARNING: string;
77
95
  /**
78
96
  * Dependency information including path and description.
79
97
  */
@@ -86,6 +104,8 @@ export interface DependencyInfo {
86
104
  path: string;
87
105
  /** Description of the dependency artifact */
88
106
  description: string;
107
+ /** True when the dependency is satisfied via skip_specs - no files exist to read */
108
+ skipped?: boolean;
89
109
  }
90
110
  /**
91
111
  * Status of a single artifact in the workflow.
@@ -95,8 +115,13 @@ export interface ArtifactStatus {
95
115
  id: string;
96
116
  /** Output path pattern */
97
117
  outputPath: string;
98
- /** Status: done, ready, or blocked */
99
- status: 'done' | 'ready' | 'blocked';
118
+ /** Status: done, skipped (via skip_specs), ready, or blocked */
119
+ status: 'done' | 'skipped' | 'ready' | 'blocked';
120
+ /** Artifact IDs this artifact directly requires (its `requires` edges).
121
+ * Present for every status so callers can compute the transitive required
122
+ * set even when the artifact is already `done` (file-existence status does
123
+ * not imply its dependencies exist). */
124
+ requires: string[];
100
125
  /** Missing dependencies (only for blocked) */
101
126
  missingDeps?: string[];
102
127
  }
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { getSchemaDir, resolveSchema } from './resolver.js';
3
+ import { getSchemaDir, resolveSchema, listSchemasWithInfo } from './resolver.js';
4
4
  import { ArtifactGraph } from './graph.js';
5
5
  import { detectCompleted } from './state.js';
6
6
  import { resolveArtifactOutputs } from './outputs.js';
@@ -21,6 +21,13 @@ export class TemplateLoadError extends Error {
21
21
  this.name = 'TemplateLoadError';
22
22
  }
23
23
  }
24
+ /**
25
+ * Warning attached to instructions for an artifact skipped via skip_specs.
26
+ * Carried in the JSON payload too, so agents driving the CLI with --json see
27
+ * the same do-not-create signal as the text output.
28
+ */
29
+ export const SKIP_SPECS_INSTRUCTIONS_WARNING = 'This change declares skip_specs: true in .openspec.yaml (no spec-level behavior changes), so this artifact is skipped.\n' +
30
+ 'Do not create spec files - they will conflict with that marker. If requirements now change, remove skip_specs from .openspec.yaml and rerun this command.';
24
31
  /**
25
32
  * Loads a template from a schema's templates directory.
26
33
  *
@@ -66,10 +73,29 @@ export function loadChangeContext(projectRoot, changeName, schemaName, options =
66
73
  const metadata = readChangeMetadata(changeDir, projectRoot) ?? undefined;
67
74
  const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName, projectRoot, {
68
75
  metadata: metadata ?? null,
76
+ projectConfig: options.projectConfig,
69
77
  });
70
78
  const schema = resolveSchema(resolvedSchemaName, projectRoot);
71
79
  const graph = ArtifactGraph.fromSchema(schema);
72
80
  const completed = detectCompleted(graph, changeDir);
81
+ // A change that declares skip_specs has no spec deltas by design, so
82
+ // artifacts generating into specs/ count as complete; otherwise the graph
83
+ // would block their dependents (e.g. tasks) on files that must not exist.
84
+ // Tracked separately so status renders them as skipped, not done.
85
+ const skippedArtifacts = new Set();
86
+ if (metadata?.skip_specs) {
87
+ for (const artifact of graph.getAllArtifacts()) {
88
+ // A schema may write generates as './specs/...' - the globs treat that
89
+ // identically to 'specs/...', so the skip set must too, or validate
90
+ // would honor the marker while instructions tell the agent to create
91
+ // the very files the conflict gate polices.
92
+ const generates = artifact.generates.replace(/^(?:\.\/)+/, '');
93
+ if (generates.startsWith('specs/') && !completed.has(artifact.id)) {
94
+ completed.add(artifact.id);
95
+ skippedArtifacts.add(artifact.id);
96
+ }
97
+ }
98
+ }
73
99
  return {
74
100
  graph,
75
101
  completed,
@@ -79,6 +105,7 @@ export function loadChangeContext(projectRoot, changeName, schemaName, options =
79
105
  projectRoot,
80
106
  ...(options.planningHome ? { planningHome: options.planningHome } : {}),
81
107
  ...(metadata ? { metadata } : {}),
108
+ ...(skippedArtifacts.size > 0 ? { skippedArtifacts } : {}),
82
109
  };
83
110
  }
84
111
  export function generateInstructions(context, artifactId, projectRoot, options = {}) {
@@ -87,7 +114,7 @@ export function generateInstructions(context, artifactId, projectRoot, options =
87
114
  throw new Error(`Artifact '${artifactId}' not found in schema '${context.schemaName}'`);
88
115
  }
89
116
  const templateContent = loadTemplate(context.schemaName, artifact.template, context.projectRoot);
90
- const dependencies = getDependencyInfo(artifact, context.graph, context.completed);
117
+ const dependencies = getDependencyInfo(artifact, context.graph, context.completed, context.skippedArtifacts);
91
118
  const unlocks = getUnlockedArtifacts(context.graph, artifactId);
92
119
  // Use projectRoot from context if not explicitly provided
93
120
  const effectiveProjectRoot = projectRoot ?? context.projectRoot;
@@ -101,10 +128,12 @@ export function generateInstructions(context, artifactId, projectRoot, options =
101
128
  // If config read fails, continue without config
102
129
  }
103
130
  }
104
- // Validate rules artifact IDs if config has rules (only once per session)
131
+ // Validate rules artifact IDs if config has rules (only once per session).
132
+ // The rules map is global while each change can use a different schema, so a
133
+ // key is only "unknown" when it matches no artifact in ANY available schema.
105
134
  if (projectConfig?.rules) {
106
- const validArtifactIds = new Set(context.graph.getAllArtifacts().map((a) => a.id));
107
- const warnings = validateConfigRules(projectConfig.rules, validArtifactIds, context.schemaName);
135
+ const validArtifactIds = new Set(listSchemasWithInfo(effectiveProjectRoot ?? undefined).flatMap((s) => s.artifacts));
136
+ const warnings = validateConfigRules(projectConfig.rules, validArtifactIds);
108
137
  // Show each unique warning only once per session
109
138
  for (const warning of warnings) {
110
139
  if (!shownWarnings.has(warning)) {
@@ -131,6 +160,9 @@ export function generateInstructions(context, artifactId, projectRoot, options =
131
160
  context: configContext,
132
161
  rules: configRules,
133
162
  ...(options.references !== undefined ? { references: options.references } : {}),
163
+ ...(context.skippedArtifacts?.has(artifact.id)
164
+ ? { skipped: true, warning: SKIP_SPECS_INSTRUCTIONS_WARNING }
165
+ : {}),
134
166
  template: templateContent,
135
167
  dependencies,
136
168
  unlocks,
@@ -139,7 +171,7 @@ export function generateInstructions(context, artifactId, projectRoot, options =
139
171
  /**
140
172
  * Gets dependency info including paths and descriptions.
141
173
  */
142
- function getDependencyInfo(artifact, graph, completed) {
174
+ function getDependencyInfo(artifact, graph, completed, skippedArtifacts) {
143
175
  return artifact.requires.map(id => {
144
176
  const depArtifact = graph.getArtifact(id);
145
177
  return {
@@ -147,11 +179,16 @@ function getDependencyInfo(artifact, graph, completed) {
147
179
  done: completed.has(id),
148
180
  path: depArtifact?.generates ?? id,
149
181
  description: depArtifact?.description ?? '',
182
+ ...(skippedArtifacts?.has(id) ? { skipped: true } : {}),
150
183
  };
151
184
  });
152
185
  }
153
186
  /**
154
187
  * Gets artifacts that become available after completing the given artifact.
188
+ *
189
+ * `getAllArtifacts()` already yields the schema's declaration order, so the list
190
+ * is returned as collected: sorting it alphabetically would have `unlocks` name
191
+ * the artifacts in a different order than `status` recommends them.
155
192
  */
156
193
  function getUnlockedArtifacts(graph, artifactId) {
157
194
  const unlocks = [];
@@ -160,7 +197,7 @@ function getUnlockedArtifacts(graph, artifactId) {
160
197
  unlocks.push(artifact.id);
161
198
  }
162
199
  }
163
- return unlocks.sort();
200
+ return unlocks;
164
201
  }
165
202
  /**
166
203
  * Formats the status of all artifacts in a change.
@@ -182,11 +219,20 @@ export function formatChangeStatus(context, options = {}) {
182
219
  resolvedOutputPath: path.join(context.changeDir, artifact.generates),
183
220
  existingOutputPaths: resolveArtifactOutputs(context.changeDir, artifact.generates),
184
221
  };
222
+ if (context.skippedArtifacts?.has(artifact.id)) {
223
+ return {
224
+ id: artifact.id,
225
+ outputPath: artifact.generates,
226
+ status: 'skipped',
227
+ requires: artifact.requires,
228
+ };
229
+ }
185
230
  if (context.completed.has(artifact.id)) {
186
231
  return {
187
232
  id: artifact.id,
188
233
  outputPath: artifact.generates,
189
234
  status: 'done',
235
+ requires: artifact.requires,
190
236
  };
191
237
  }
192
238
  if (ready.has(artifact.id)) {
@@ -194,12 +240,14 @@ export function formatChangeStatus(context, options = {}) {
194
240
  id: artifact.id,
195
241
  outputPath: artifact.generates,
196
242
  status: 'ready',
243
+ requires: artifact.requires,
197
244
  };
198
245
  }
199
246
  return {
200
247
  id: artifact.id,
201
248
  outputPath: artifact.generates,
202
249
  status: 'blocked',
250
+ requires: artifact.requires,
203
251
  missingDeps: blocked[artifact.id] ?? [],
204
252
  };
205
253
  });
@@ -1,3 +1,4 @@
1
+ import * as fs from 'node:fs';
1
2
  import type { SchemaYaml } from './types.js';
2
3
  /**
3
4
  * Error thrown when loading a schema fails.
@@ -22,6 +23,19 @@ export declare function getUserSchemasDir(): string;
22
23
  * @returns The path to the project's schemas directory
23
24
  */
24
25
  export declare function getProjectSchemasDir(projectRoot: string): string;
26
+ /**
27
+ * Determines whether a directory entry represents a schema directory candidate.
28
+ *
29
+ * Returns true for real directories and for symlinks whose target is a
30
+ * directory. `fs.Dirent.isDirectory()` reports the raw entry type, so a symlink
31
+ * (even one pointing at a directory) has `isDirectory() === false`; we
32
+ * dereference such entries via `fs.statSync` to admit symlinked schema dirs
33
+ * while still rejecting symlinks-to-files and broken/dangling symlinks.
34
+ *
35
+ * @param parentDir - The directory containing the entry
36
+ * @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })`
37
+ */
38
+ export declare function isSchemaDir(parentDir: string, entry: fs.Dirent): boolean;
25
39
  /**
26
40
  * Resolves a schema name to its directory path.
27
41
  *
@@ -39,6 +39,34 @@ export function getUserSchemasDir() {
39
39
  export function getProjectSchemasDir(projectRoot) {
40
40
  return path.join(projectRoot, 'openspec', 'schemas');
41
41
  }
42
+ /**
43
+ * Determines whether a directory entry represents a schema directory candidate.
44
+ *
45
+ * Returns true for real directories and for symlinks whose target is a
46
+ * directory. `fs.Dirent.isDirectory()` reports the raw entry type, so a symlink
47
+ * (even one pointing at a directory) has `isDirectory() === false`; we
48
+ * dereference such entries via `fs.statSync` to admit symlinked schema dirs
49
+ * while still rejecting symlinks-to-files and broken/dangling symlinks.
50
+ *
51
+ * @param parentDir - The directory containing the entry
52
+ * @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })`
53
+ */
54
+ export function isSchemaDir(parentDir, entry) {
55
+ if (entry.isDirectory()) {
56
+ return true;
57
+ }
58
+ if (entry.isSymbolicLink()) {
59
+ try {
60
+ // statSync follows the link; isDirectory() reflects the target type.
61
+ return fs.statSync(path.join(parentDir, entry.name)).isDirectory();
62
+ }
63
+ catch {
64
+ // Broken symlink (dangling target) — statSync throws; treat as non-dir.
65
+ return false;
66
+ }
67
+ }
68
+ return false;
69
+ }
42
70
  /**
43
71
  * Resolves a schema name to its directory path.
44
72
  *
@@ -134,7 +162,7 @@ export function listSchemas(projectRoot) {
134
162
  const packageDir = getPackageSchemasDir();
135
163
  if (fs.existsSync(packageDir)) {
136
164
  for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) {
137
- if (entry.isDirectory()) {
165
+ if (isSchemaDir(packageDir, entry)) {
138
166
  const schemaPath = path.join(packageDir, entry.name, 'schema.yaml');
139
167
  if (fs.existsSync(schemaPath)) {
140
168
  schemas.add(entry.name);
@@ -146,7 +174,7 @@ export function listSchemas(projectRoot) {
146
174
  const userDir = getUserSchemasDir();
147
175
  if (fs.existsSync(userDir)) {
148
176
  for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) {
149
- if (entry.isDirectory()) {
177
+ if (isSchemaDir(userDir, entry)) {
150
178
  const schemaPath = path.join(userDir, entry.name, 'schema.yaml');
151
179
  if (fs.existsSync(schemaPath)) {
152
180
  schemas.add(entry.name);
@@ -159,7 +187,7 @@ export function listSchemas(projectRoot) {
159
187
  const projectDir = getProjectSchemasDir(projectRoot);
160
188
  if (fs.existsSync(projectDir)) {
161
189
  for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) {
162
- if (entry.isDirectory()) {
190
+ if (isSchemaDir(projectDir, entry)) {
163
191
  const schemaPath = path.join(projectDir, entry.name, 'schema.yaml');
164
192
  if (fs.existsSync(schemaPath)) {
165
193
  schemas.add(entry.name);
@@ -184,7 +212,7 @@ export function listSchemasWithInfo(projectRoot) {
184
212
  const projectDir = getProjectSchemasDir(projectRoot);
185
213
  if (fs.existsSync(projectDir)) {
186
214
  for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) {
187
- if (entry.isDirectory()) {
215
+ if (isSchemaDir(projectDir, entry)) {
188
216
  const schemaPath = path.join(projectDir, entry.name, 'schema.yaml');
189
217
  if (fs.existsSync(schemaPath)) {
190
218
  try {
@@ -209,7 +237,7 @@ export function listSchemasWithInfo(projectRoot) {
209
237
  const userDir = getUserSchemasDir();
210
238
  if (fs.existsSync(userDir)) {
211
239
  for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) {
212
- if (entry.isDirectory() && !seenNames.has(entry.name)) {
240
+ if (isSchemaDir(userDir, entry) && !seenNames.has(entry.name)) {
213
241
  const schemaPath = path.join(userDir, entry.name, 'schema.yaml');
214
242
  if (fs.existsSync(schemaPath)) {
215
243
  try {
@@ -233,7 +261,7 @@ export function listSchemasWithInfo(projectRoot) {
233
261
  const packageDir = getPackageSchemasDir();
234
262
  if (fs.existsSync(packageDir)) {
235
263
  for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) {
236
- if (entry.isDirectory() && !seenNames.has(entry.name)) {
264
+ if (isSchemaDir(packageDir, entry) && !seenNames.has(entry.name)) {
237
265
  const schemaPath = path.join(packageDir, entry.name, 'schema.yaml');
238
266
  if (fs.existsSync(schemaPath)) {
239
267
  try {