@fission-ai/openspec 1.7.0 → 1.9.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 (113) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/index.d.ts +15 -0
  3. package/dist/cli/index.js +39 -5
  4. package/dist/commands/change.js +16 -1
  5. package/dist/commands/config.d.ts +5 -0
  6. package/dist/commands/config.js +5 -1
  7. package/dist/commands/schema.js +281 -47
  8. package/dist/commands/spec.js +30 -6
  9. package/dist/commands/validate.d.ts +23 -0
  10. package/dist/commands/validate.js +148 -4
  11. package/dist/commands/workflow/instructions.js +32 -27
  12. package/dist/commands/workflow/schemas.d.ts +2 -0
  13. package/dist/commands/workflow/schemas.js +9 -2
  14. package/dist/commands/workflow/status.js +2 -2
  15. package/dist/commands/workflow/templates.js +15 -5
  16. package/dist/core/archive.d.ts +14 -0
  17. package/dist/core/archive.js +1272 -151
  18. package/dist/core/artifact-graph/index.d.ts +1 -1
  19. package/dist/core/artifact-graph/index.js +1 -1
  20. package/dist/core/artifact-graph/instruction-loader.d.ts +3 -1
  21. package/dist/core/artifact-graph/instruction-loader.js +15 -5
  22. package/dist/core/artifact-graph/outputs.d.ts +1 -0
  23. package/dist/core/artifact-graph/outputs.js +67 -5
  24. package/dist/core/artifact-graph/resolver.d.ts +0 -12
  25. package/dist/core/artifact-graph/resolver.js +49 -9
  26. package/dist/core/artifact-graph/types.js +21 -3
  27. package/dist/core/available-tools.d.ts +2 -2
  28. package/dist/core/available-tools.js +14 -3
  29. package/dist/core/change-metadata/schema.d.ts +1 -0
  30. package/dist/core/change-metadata/schema.js +7 -0
  31. package/dist/core/change-status-policy.js +2 -2
  32. package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
  33. package/dist/core/command-generation/adapters/command-code.js +34 -0
  34. package/dist/core/command-generation/adapters/index.d.ts +1 -0
  35. package/dist/core/command-generation/adapters/index.js +1 -0
  36. package/dist/core/command-generation/registry.js +2 -0
  37. package/dist/core/completions/command-registry.js +13 -0
  38. package/dist/core/config-schema.d.ts +3 -0
  39. package/dist/core/config-schema.js +28 -1
  40. package/dist/core/config.d.ts +4 -0
  41. package/dist/core/config.js +40 -17
  42. package/dist/core/file-state.js +56 -24
  43. package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
  44. package/dist/core/github-copilot/cloud-agent.js +497 -0
  45. package/dist/core/global-config.d.ts +11 -0
  46. package/dist/core/init.d.ts +23 -0
  47. package/dist/core/init.js +253 -46
  48. package/dist/core/legacy-cleanup.d.ts +10 -0
  49. package/dist/core/legacy-cleanup.js +52 -28
  50. package/dist/core/migration.d.ts +6 -4
  51. package/dist/core/migration.js +78 -22
  52. package/dist/core/parsers/requirement-blocks.d.ts +10 -0
  53. package/dist/core/parsers/requirement-blocks.js +93 -1
  54. package/dist/core/parsers/requirement-text.d.ts +8 -0
  55. package/dist/core/parsers/requirement-text.js +4 -3
  56. package/dist/core/parsers/spec-structure.d.ts +1 -1
  57. package/dist/core/parsers/spec-structure.js +17 -1
  58. package/dist/core/profile-sync-drift.js +40 -5
  59. package/dist/core/project-config.d.ts +3 -0
  60. package/dist/core/project-config.js +31 -1
  61. package/dist/core/root-selection.d.ts +1 -1
  62. package/dist/core/shared/index.d.ts +1 -0
  63. package/dist/core/shared/index.js +1 -0
  64. package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
  65. package/dist/core/shared/skill-content-equivalence.js +41 -0
  66. package/dist/core/shared/skill-paths.d.ts +13 -0
  67. package/dist/core/shared/skill-paths.js +23 -0
  68. package/dist/core/shared/tool-detection.js +66 -35
  69. package/dist/core/shared-skill-target.d.ts +31 -0
  70. package/dist/core/shared-skill-target.js +186 -0
  71. package/dist/core/specs-apply.d.ts +72 -0
  72. package/dist/core/specs-apply.js +459 -59
  73. package/dist/core/templates/skill-templates.d.ts +1 -1
  74. package/dist/core/templates/skill-templates.js +1 -1
  75. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  76. package/dist/core/templates/workflows/apply-change.js +25 -182
  77. package/dist/core/templates/workflows/archive-change.js +8 -4
  78. package/dist/core/templates/workflows/bulk-archive-change.js +30 -26
  79. package/dist/core/templates/workflows/continue-change.js +6 -6
  80. package/dist/core/templates/workflows/explore.js +42 -18
  81. package/dist/core/templates/workflows/onboard.js +9 -3
  82. package/dist/core/templates/workflows/propose.js +72 -20
  83. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  84. package/dist/core/templates/workflows/store-selection.js +1 -1
  85. package/dist/core/templates/workflows/sync-specs.js +96 -18
  86. package/dist/core/templates/workflows/update-change.js +10 -8
  87. package/dist/core/update.d.ts +1 -0
  88. package/dist/core/update.js +136 -30
  89. package/dist/core/validation/task-numbering.d.ts +16 -0
  90. package/dist/core/validation/task-numbering.js +57 -0
  91. package/dist/core/validation/validator.d.ts +26 -3
  92. package/dist/core/validation/validator.js +189 -19
  93. package/dist/core/version-check.js +6 -11
  94. package/dist/telemetry/config.d.ts +3 -4
  95. package/dist/telemetry/index.d.ts +12 -5
  96. package/dist/telemetry/index.js +27 -9
  97. package/dist/utils/change-metadata.d.ts +17 -4
  98. package/dist/utils/change-metadata.js +26 -4
  99. package/dist/utils/ci.d.ts +12 -0
  100. package/dist/utils/ci.js +16 -0
  101. package/dist/utils/command-references.d.ts +14 -2
  102. package/dist/utils/command-references.js +45 -3
  103. package/dist/utils/file-system.d.ts +10 -0
  104. package/dist/utils/file-system.js +70 -0
  105. package/dist/utils/interactive.d.ts +52 -0
  106. package/dist/utils/interactive.js +135 -0
  107. package/dist/utils/spec-discovery.d.ts +4 -4
  108. package/dist/utils/spec-discovery.js +19 -6
  109. package/dist/utils/task-progress.d.ts +51 -1
  110. package/dist/utils/task-progress.js +93 -41
  111. package/package.json +1 -1
  112. package/schemas/spec-driven/schema.yaml +8 -6
  113. package/schemas/spec-driven/templates/proposal.md +6 -4
@@ -5,14 +5,54 @@
5
5
  * Applies delta specs from a change to main specs without archiving.
6
6
  */
7
7
  import { promises as fs } from 'fs';
8
+ import { randomUUID } from 'crypto';
8
9
  import path from 'path';
9
10
  import chalk from 'chalk';
10
- import { extractRequirementsSection, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
11
+ import { extractRequirementsSection, findMissingCurrentScenarios, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
11
12
  import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
12
13
  import { buildCodeFenceMask } from './parsers/code-fence.js';
13
14
  import { MarkdownParser } from './parsers/markdown-parser.js';
14
15
  import { MIN_PURPOSE_LENGTH } from './validation/constants.js';
15
16
  import { discoverSpecFiles } from '../utils/spec-discovery.js';
17
+ import { FileSystemUtils } from '../utils/file-system.js';
18
+ function isLexicallyWithin(allowedDirectory, targetPath) {
19
+ const relative = path.relative(path.resolve(allowedDirectory), path.resolve(targetPath));
20
+ return (relative === '' ||
21
+ (relative !== '..' &&
22
+ !relative.startsWith(`..${path.sep}`) &&
23
+ !path.isAbsolute(relative)));
24
+ }
25
+ function resolveTrustedSpecPath(specsRoot, specPath) {
26
+ if (!isLexicallyWithin(specsRoot, specPath)) {
27
+ throw new Error(`Path is outside the allowed directory: ${specPath}`);
28
+ }
29
+ try {
30
+ // Preserve spec.md links that remain inside the overall specs tree.
31
+ FileSystemUtils.assertPathWithin(specsRoot, specPath);
32
+ const root = FileSystemUtils.canonicalizeExistingPath(specsRoot);
33
+ return {
34
+ root,
35
+ // Rebase onto the canonical root so missing targets also work when the
36
+ // project is reached through an OS path alias (for example /var on macOS).
37
+ file: path.join(root, path.relative(path.resolve(specsRoot), path.resolve(specPath))),
38
+ };
39
+ }
40
+ catch {
41
+ // Direct capability directories may intentionally be monorepo symlinks.
42
+ // Freeze their canonical location as the trust root so later swaps are
43
+ // rejected while a nested spec.md link still cannot escape.
44
+ const root = FileSystemUtils.canonicalizeExistingPath(path.dirname(specPath));
45
+ const file = path.join(root, path.basename(specPath));
46
+ FileSystemUtils.assertPathWithin(root, file);
47
+ return { root, file };
48
+ }
49
+ }
50
+ function assertTrustedSpecPath(root, specPath) {
51
+ if (FileSystemUtils.canonicalizeExistingPath(root) !== path.resolve(root)) {
52
+ throw new Error(`Path is outside the allowed directory: ${specPath}`);
53
+ }
54
+ FileSystemUtils.assertPathWithin(root, specPath);
55
+ }
16
56
  // -----------------------------------------------------------------------------
17
57
  // Public API
18
58
  // -----------------------------------------------------------------------------
@@ -28,10 +68,12 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
28
68
  const discovered = await discoverSpecFiles(changeSpecsDir);
29
69
  for (const { id, specFile } of discovered) {
30
70
  const targetFile = path.join(mainSpecsDir, ...id.split('/'), 'spec.md');
71
+ const source = resolveTrustedSpecPath(changeSpecsDir, specFile);
72
+ const target = resolveTrustedSpecPath(mainSpecsDir, targetFile);
31
73
  // Check if target exists
32
74
  let exists = false;
33
75
  try {
34
- await fs.access(targetFile);
76
+ await fs.access(target.file);
35
77
  exists = true;
36
78
  }
37
79
  catch {
@@ -39,8 +81,10 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
39
81
  }
40
82
  updates.push({
41
83
  id,
42
- source: specFile,
43
- target: targetFile,
84
+ sourceRoot: source.root,
85
+ source: source.file,
86
+ targetRoot: target.root,
87
+ target: target.file,
44
88
  exists,
45
89
  });
46
90
  }
@@ -61,6 +105,7 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
61
105
  }
62
106
  };
63
107
  // Read change spec content (delta-format expected)
108
+ assertTrustedSpecPath(update.sourceRoot, update.source);
64
109
  const changeContent = await fs.readFile(update.source, 'utf-8');
65
110
  // Parse deltas from the change spec file
66
111
  const plan = parseDeltaSpec(changeContent);
@@ -152,6 +197,7 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
152
197
  const deltaPurpose = extractPurposeSection(changeContent);
153
198
  let targetContent;
154
199
  let isNewSpec = false;
200
+ assertTrustedSpecPath(update.targetRoot, update.target);
155
201
  try {
156
202
  targetContent = await fs.readFile(update.target, 'utf-8');
157
203
  // A delta Purpose only seeds a spec that does not exist yet. Say so rather
@@ -211,6 +257,7 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
211
257
  // Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED
212
258
  // RENAMED
213
259
  let renamedApplied = 0;
260
+ const renamedTargets = new Map();
214
261
  for (const r of plan.renamed) {
215
262
  const from = normalizeRequirementName(r.from);
216
263
  const to = normalizeRequirementName(r.to);
@@ -244,6 +291,7 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
244
291
  };
245
292
  nameToBlock.delete(from);
246
293
  nameToBlock.set(to, renamedBlock);
294
+ renamedTargets.set(from, to);
247
295
  renamedApplied++;
248
296
  }
249
297
  // REMOVED
@@ -323,6 +371,27 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
323
371
  keptOrder.push(replacement);
324
372
  seen.add(key);
325
373
  }
374
+ // A block's raw runs to the next header the parser RECOGNISES, so a note
375
+ // under an unrecognized heading can be absorbed into the requirement.
376
+ // Warn only when the replacement from this same original block drops the
377
+ // full absorbed suffix. RENAMED carries the original raw content under a
378
+ // new map key, and MODIFIED may repeat the suffix deliberately; neither is
379
+ // data loss.
380
+ const renamedTarget = renamedTargets.get(key);
381
+ const replacementFromOriginal = replacement ?? (renamedTarget ? nameToBlock.get(renamedTarget) : undefined);
382
+ if (replacementFromOriginal !== block) {
383
+ const foreign = firstForeignTail(block.raw);
384
+ const replacementRaw = replacementFromOriginal?.raw;
385
+ const normalizedForeign = foreign ? normalizeBlockRaw(foreign.raw) : '';
386
+ const keepsForeignTail = foreign !== undefined &&
387
+ replacementRaw !== undefined &&
388
+ countOccurrences(normalizeBlockRaw(replacementRaw), normalizedForeign) >=
389
+ countOccurrences(normalizeBlockRaw(block.raw), normalizedForeign);
390
+ if (foreign && !keepsForeignTail) {
391
+ warn(`${specName} - "${foreign.heading}" sits inside requirement "${block.name}" and goes with it. ` +
392
+ 'Move it under its own requirement, or above `## Requirements`, to keep it.');
393
+ }
394
+ }
326
395
  }
327
396
  // Append any newly added that were not in original order
328
397
  for (const [key, block] of nameToBlock.entries()) {
@@ -335,10 +404,17 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
335
404
  .concat(keptOrder.map((b) => b.raw))
336
405
  .join('\n\n')
337
406
  .trimEnd();
338
- const rebuilt = [parts.before.trimEnd(), parts.headerLine, reqBody, parts.after]
339
- .filter((s, idx) => !(idx === 0 && s === ''))
340
- .join('\n')
341
- .replace(/\n{3,}/g, '\n\n');
407
+ // The blank lines surrounding `## Requirements` belong to no slice: `before`
408
+ // and `after` carry at most one trailing/leading '\n' by construction, and
409
+ // the body is trimEnd()ed. Joining the slices with a bare '\n' therefore
410
+ // glued the heading to the Purpose paragraph and the first requirement, so
411
+ // every archive rewrote a well-formatted spec into that shape. Separate
412
+ // non-empty slices with one blank line instead.
413
+ const rebuilt = [parts.before.trimEnd(), parts.headerLine, reqBody, parts.after.trim()]
414
+ .filter((s) => s !== '')
415
+ .join('\n\n')
416
+ .replace(/\n{3,}/g, '\n\n')
417
+ .trimEnd() + '\n';
342
418
  return {
343
419
  rebuilt,
344
420
  counts: {
@@ -348,18 +424,393 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
348
424
  renamed: renamedApplied,
349
425
  },
350
426
  warnings,
427
+ noRequirementBlocks: keptOrder.length === 0,
428
+ // Read off the ORIGINAL requirements section, not the rebuilt one. Anything
429
+ // after the last `### Requirement:` header belongs to that block's raw and
430
+ // is discarded with it, so a rebuilt-body scan only ever sees headings above
431
+ // the first requirement - it would veto `### Notes` written before the
432
+ // requirements and miss the identical heading written after them.
433
+ unaccountedContent: contentTheMergeCannotName(parts),
351
434
  };
352
435
  }
436
+ /**
437
+ * The suffix of a requirement block that begins with content the requirement
438
+ * parser did not recognize as a boundary: a `#`, `##`, or `###` heading after
439
+ * the block's own header.
440
+ *
441
+ * `####` is excluded: a requirement's `#### Scenario:` headings are its own.
442
+ * Fenced lines are skipped, so a heading inside an example does not count.
443
+ *
444
+ * Approximate on purpose, and only ever used to WARN. A `#` line inside a
445
+ * scenario looks the same as a note written below the requirement, and no
446
+ * line-based rule separates them; a wrong warning costs a line of output, while
447
+ * acting on a wrong answer would rewrite the spec.
448
+ */
449
+ function firstForeignTail(raw) {
450
+ const lines = raw.replace(/\r\n?/g, '\n').split('\n');
451
+ const fenceMask = buildCodeFenceMask(lines);
452
+ for (let index = 1; index < lines.length; index++) {
453
+ if (fenceMask[index])
454
+ continue;
455
+ if (/^ {0,3}#{1,3}(?:[ \t]|$)/.test(lines[index])) {
456
+ return {
457
+ heading: lines[index].trim(),
458
+ raw: lines.slice(index).join('\n').trimEnd(),
459
+ };
460
+ }
461
+ }
462
+ return undefined;
463
+ }
464
+ /**
465
+ * The non-blank lines of a spec that are not part of what a retirement is able
466
+ * to name: the title, the `## Purpose` section, the `## Requirements` header,
467
+ * and each requirement block's own header, statement and scenario bullets.
468
+ *
469
+ * Deliberately whole-file. Auditing a subset of the slices is what let authored
470
+ * prose inside a removed block, and content above the requirements section, be
471
+ * deleted unmentioned.
472
+ */
473
+ function contentTheMergeCannotName(parts) {
474
+ const leftovers = [];
475
+ // Above the requirements section: the title and the Purpose section are
476
+ // expected; anything else is authored content the deletion would take.
477
+ const beforeLines = parts.before.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').split('\n');
478
+ const beforeMask = buildCodeFenceMask(beforeLines);
479
+ let inPurpose = false;
480
+ let titleSeen = false;
481
+ let previousLine = '';
482
+ for (let index = 0; index < beforeLines.length; index++) {
483
+ const line = beforeLines[index];
484
+ if (!line.trim()) {
485
+ previousLine = '';
486
+ continue;
487
+ }
488
+ if (!beforeMask[index]) {
489
+ const section = line.match(/^ {0,3}##\s+(.+?)\s*$/);
490
+ if (section) {
491
+ inPurpose = /^purpose$/i.test(section[1].trim());
492
+ if (!inPurpose)
493
+ leftovers.push(line.trim());
494
+ previousLine = line;
495
+ continue;
496
+ }
497
+ // `##` is not the only way to open a section. A setext underline turns
498
+ // the line above it into a heading, and raw HTML says so outright - a
499
+ // reader sees a sibling of `## Purpose`, not more of its body. Treating
500
+ // everything up to the next ATX `##` as Purpose swallowed those whole and
501
+ // deleted them, reported as nothing but "Purpose".
502
+ const setext = inPurpose && previousLine.trim() && /^ {0,3}(=+|-+)\s*$/.test(line);
503
+ const htmlHeading = /^ {0,3}<h[1-6]\b/i.test(line);
504
+ if (setext || htmlHeading) {
505
+ leftovers.push((setext ? previousLine : line).trim());
506
+ inPurpose = false;
507
+ previousLine = line;
508
+ continue;
509
+ }
510
+ if (/^ {0,3}#\s+.+$/.test(line)) {
511
+ if (!titleSeen && !inPurpose) {
512
+ titleSeen = true;
513
+ }
514
+ else {
515
+ leftovers.push(line.trim());
516
+ inPurpose = false;
517
+ }
518
+ previousLine = line;
519
+ continue;
520
+ }
521
+ }
522
+ previousLine = line;
523
+ if (inPurpose)
524
+ continue;
525
+ leftovers.push(line.trim());
526
+ }
527
+ // Between the header and the first requirement, and past the section's end.
528
+ for (const slice of [parts.preamble, parts.after]) {
529
+ for (const line of slice.split('\n')) {
530
+ if (line.trim())
531
+ leftovers.push(line.trim());
532
+ }
533
+ }
534
+ // Inside each requirement block, everything the block parser did not treat as
535
+ // a new header rides along in `raw` - tables, fences, comments, prose written
536
+ // below the scenarios. Only a requirement's own parts are expected here.
537
+ for (const block of parts.bodyBlocks) {
538
+ const foreignTail = firstForeignTail(block.raw);
539
+ if (foreignTail)
540
+ leftovers.push(foreignTail.heading);
541
+ const lines = block.raw.replace(/\r\n?/g, '\n').split('\n');
542
+ const mask = buildCodeFenceMask(lines);
543
+ let seenScenario = false;
544
+ // A scenario's bullets run unbroken beneath its header. A blank line after
545
+ // them ends the scenario, so bullets written past that point are a note the
546
+ // author added, not part of the scenario - and deleting the file would take
547
+ // them. Treating every bullet as a scenario's own is what let an
548
+ // operational note below the last scenario be deleted unmentioned.
549
+ let inScenarioBullets = false;
550
+ let bulletsSeen = false;
551
+ for (let index = 0; index < lines.length; index++) {
552
+ const line = lines[index];
553
+ if (!line.trim()) {
554
+ // Only a blank that follows actual bullets closes the run, so a blank
555
+ // between a scenario header and its first bullet is not a boundary.
556
+ if (bulletsSeen)
557
+ inScenarioBullets = false;
558
+ continue;
559
+ }
560
+ if (index === 0)
561
+ continue; // the `### Requirement:` header itself
562
+ // Fenced lines render as a code block inside the requirement, so they are
563
+ // its own content however they are spelled - a `### Requirement:` in an
564
+ // example is not a heading to any reader. Flagging them made a spec that
565
+ // merely documents a command unretirable.
566
+ if (mask[index])
567
+ continue;
568
+ if (index > 1 &&
569
+ /^ {0,3}(?:=+|-+)\s*$/.test(line) &&
570
+ lines[index - 1].trim()) {
571
+ leftovers.push(lines[index - 1].trim());
572
+ continue;
573
+ }
574
+ if (/^ {0,3}####\s+Scenario:/i.test(line)) {
575
+ seenScenario = true;
576
+ inScenarioBullets = true;
577
+ bulletsSeen = false;
578
+ continue;
579
+ }
580
+ if (/^\s*(?:[-*]|\d+[.)])\s/.test(line)) {
581
+ if (inScenarioBullets) {
582
+ bulletsSeen = true;
583
+ continue;
584
+ }
585
+ // A bullet outside a scenario. Before the first scenario it is part of
586
+ // the requirement statement; after one it is the author's own note.
587
+ if (!seenScenario)
588
+ continue;
589
+ leftovers.push(line.trim());
590
+ continue;
591
+ }
592
+ // Free prose above the first scenario is the requirement statement.
593
+ if (!seenScenario && !/^\s*[|<]/.test(line))
594
+ continue;
595
+ leftovers.push(line.trim());
596
+ }
597
+ }
598
+ return [...new Set(leftovers)];
599
+ }
353
600
  function normalizeBlockRaw(raw) {
354
601
  return raw.replace(/\r\n?/g, '\n').trim();
355
602
  }
603
+ /** Count non-overlapping copies so one retained duplicate cannot mask another copy's loss. */
604
+ function countOccurrences(haystack, needle) {
605
+ if (!needle)
606
+ return 0;
607
+ let count = 0;
608
+ let start = 0;
609
+ while ((start = haystack.indexOf(needle, start)) !== -1) {
610
+ count++;
611
+ start += needle.length;
612
+ }
613
+ return count;
614
+ }
615
+ /**
616
+ * Retire a capability whose last requirement a delta removed: delete its main
617
+ * spec and prune any directories the deletion leaves empty. Returns false when
618
+ * there was nothing to delete.
619
+ *
620
+ * Gated by the caller on the change's `retire_capabilities` marker, so the one
621
+ * archive action that removes a file from `openspec/specs/` is always something
622
+ * the author asked for rather than something inferred from a delta's shape. The
623
+ * file is recoverable from git, which the report names; applying REMOVED already
624
+ * deletes requirement content from a main spec, so deleting the spec once
625
+ * nothing is left is the same operation carried to its end rather than a new
626
+ * kind of act.
627
+ *
628
+ * Only the generated `spec.md` is removed - a directory holding anything else (a
629
+ * nested capability, a hand-kept note) is left in place.
630
+ *
631
+ * The target must resolve inside the selected specs root. A capability-directory
632
+ * symlink must not turn a retirement marker into authorization to delete an
633
+ * unrelated external file. A symlinked `spec.md` itself is safe: unlink removes
634
+ * the link and leaves its target alone.
635
+ *
636
+ * Directory pruning IS bounded, by REAL paths rather than string prefixes:
637
+ * `path.resolve` collapses `..` but does not resolve symlinks, and `readdir` and
638
+ * `rmdir` both follow them, so a symlinked capability directory would otherwise
639
+ * let the walk delete directories outside the specs root entirely.
640
+ */
641
+ export async function retireSpec(update, mainSpecsDir, options = {}) {
642
+ if (options.deferDelete && options.verifyDisplaced === undefined) {
643
+ throw new Error('Deferred retirement requires displaced-file verification.');
644
+ }
645
+ // Resolved before the unlink, while the link still exists, so the report can
646
+ // name the file that actually goes when a symlink points out of the tree.
647
+ // A symlinked `spec.md` is excluded: `realpath` would follow it, but `unlink`
648
+ // removes the link and leaves the target alone, so naming the target would
649
+ // claim a file was deleted that is still there.
650
+ let realSource;
651
+ try {
652
+ const link = await fs.lstat(update.target);
653
+ realSource = link.isSymbolicLink() ? undefined : await fs.realpath(update.target);
654
+ }
655
+ catch (error) {
656
+ if (error.code === 'ENOENT')
657
+ return { retired: false };
658
+ throw new Error(`Could not retire capability '${update.id}': could not verify ${update.target} ` +
659
+ `before deletion (${error instanceof Error ? error.message : String(error)}).`);
660
+ }
661
+ if (realSource !== undefined) {
662
+ let inside;
663
+ try {
664
+ inside = await isInsideRealDir(realSource, mainSpecsDir);
665
+ }
666
+ catch (error) {
667
+ throw new Error(`Could not retire capability '${update.id}': could not verify that ${update.target} ` +
668
+ `is inside ${mainSpecsDir} (${error instanceof Error ? error.message : String(error)}).`);
669
+ }
670
+ if (!inside) {
671
+ throw new Error(`Could not retire capability '${update.id}': ${update.target} resolves outside ` +
672
+ `${mainSpecsDir}. Remove the external file by hand, or replace the symlink and rerun.`);
673
+ }
674
+ }
675
+ let displacedPath;
676
+ try {
677
+ await options.beforeMutate?.();
678
+ if (options.verifyDisplaced) {
679
+ const displaced = `${update.target}.openspec-retire-${randomUUID()}`;
680
+ displacedPath = displaced;
681
+ await fs.rename(update.target, displaced);
682
+ try {
683
+ await options.verifyDisplaced(displaced);
684
+ try {
685
+ await fs.lstat(update.target);
686
+ throw new Error(`A concurrent file appeared at ${update.target} while archive was retiring it.`);
687
+ }
688
+ catch (targetError) {
689
+ if (targetError.code !== 'ENOENT')
690
+ throw targetError;
691
+ }
692
+ if (!options.deferDelete)
693
+ await fs.unlink(displaced);
694
+ }
695
+ catch (error) {
696
+ try {
697
+ await fs.lstat(update.target);
698
+ throw new Error(`${error instanceof Error ? error.message : String(error)} ` +
699
+ `A concurrent file now occupies ${update.target}; the displaced spec was retained at ${displaced}.`);
700
+ }
701
+ catch (targetError) {
702
+ if (targetError.code !== 'ENOENT')
703
+ throw targetError;
704
+ }
705
+ await fs.rename(displaced, update.target);
706
+ throw error;
707
+ }
708
+ }
709
+ else {
710
+ await fs.unlink(update.target);
711
+ }
712
+ }
713
+ catch (error) {
714
+ if (error.code === 'ENOENT')
715
+ return { retired: false };
716
+ // A bare errno here reads as an internal failure; say what was being
717
+ // attempted so the message is actionable on its own.
718
+ throw new Error(`Could not retire capability '${update.id}': failed to delete ${update.target} ` +
719
+ `(${error.message}). Remove it by hand, then rerun the archive.`);
720
+ }
721
+ if (!options.deferDelete) {
722
+ await pruneEmptyDirs(path.dirname(update.target), mainSpecsDir);
723
+ }
724
+ const nominal = options.displayPath ?? `openspec/specs/${update.id}/spec.md`;
725
+ if (!options.silent) {
726
+ console.log(`Retiring ${nominal}: all requirements removed.`);
727
+ }
728
+ // `resolvedPath` is always the file that was actually unlinked - callers need
729
+ // it to report a path git will accept, since the nominal one is built from
730
+ // the capability id and can differ in case, or point through a symlink.
731
+ return {
732
+ retired: true,
733
+ ...(realSource ? { resolvedPath: realSource } : {}),
734
+ ...(options.deferDelete && displacedPath ? { displacedPath } : {}),
735
+ };
736
+ }
737
+ export async function finalizeRetiredSpec(target, displacedPath, mainSpecsDir) {
738
+ await fs.unlink(displacedPath);
739
+ await pruneEmptyDirs(path.dirname(target), mainSpecsDir);
740
+ }
741
+ /** Whether `realPath` (already canonical) sits under the real `dir`. */
742
+ async function isInsideRealDir(realPath, dir) {
743
+ const realDir = await fs.realpath(dir);
744
+ return realPath.startsWith(realDir + path.sep);
745
+ }
746
+ /**
747
+ * Remove now-empty directories from `startDir` upward, never leaving the real
748
+ * `boundaryDir` and never removing that directory itself.
749
+ *
750
+ * The boundary is a parameter rather than the specs root directly so the walk's
751
+ * containment is stated at the call site, where the root it must not escape is
752
+ * the thing being reasoned about.
753
+ *
754
+ * The guard re-runs every iteration, so stepping to the LEXICAL parent is safe:
755
+ * a parent that is not the real one is simply re-resolved and rejected. Errors
756
+ * are swallowed and end the walk - ENOTEMPTY and ENOENT are correct outcomes (a
757
+ * file arriving mid-walk must win), and a permissions failure leaves an empty
758
+ * directory behind, which the next successful archive clears.
759
+ *
760
+ * Not race-free: an attacker who can swap an ancestor between the check and the
761
+ * `rmdir` could get an empty directory outside the root removed. Closing that
762
+ * needs fd-relative syscalls Node does not expose, and it requires local write
763
+ * access to `openspec/specs` during an archive.
764
+ */
765
+ async function pruneEmptyDirs(startDir, boundaryDir) {
766
+ let boundary;
767
+ try {
768
+ boundary = await fs.realpath(boundaryDir);
769
+ }
770
+ catch {
771
+ return;
772
+ }
773
+ let dir = startDir;
774
+ for (;;) {
775
+ let realDir;
776
+ try {
777
+ // lstat first: rmdir on a symlink fails anyway, but resolving one would
778
+ // walk us out of the tree, and the parent we then step to would be wrong.
779
+ const link = await fs.lstat(dir);
780
+ if (link.isSymbolicLink())
781
+ return;
782
+ realDir = await fs.realpath(dir);
783
+ }
784
+ catch {
785
+ return;
786
+ }
787
+ // Strictly inside the real boundary - the boundary itself is never pruned.
788
+ if (realDir === boundary || !realDir.startsWith(boundary + path.sep))
789
+ return;
790
+ try {
791
+ const entries = await fs.readdir(dir);
792
+ if (entries.length > 0)
793
+ return;
794
+ await fs.rmdir(dir);
795
+ }
796
+ catch {
797
+ return;
798
+ }
799
+ dir = path.dirname(dir);
800
+ }
801
+ }
356
802
  /**
357
803
  * Write an updated spec to disk.
358
804
  */
359
805
  export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
806
+ assertTrustedSpecPath(update.targetRoot, update.target);
360
807
  // Create target directory if needed
361
808
  const targetDir = path.dirname(update.target);
362
809
  await fs.mkdir(targetDir, { recursive: true });
810
+ await options.beforeMutate?.();
811
+ // Preserve the established in-place write semantics: symlink referents,
812
+ // hard-linked specs, ACLs, extended attributes, and filesystems without hard
813
+ // links must continue to behave as they did before capability retirement.
363
814
  await fs.writeFile(update.target, rebuilt);
364
815
  if (options.silent)
365
816
  return;
@@ -469,55 +920,4 @@ export function buildSpecSkeleton(specFolderName, changeName, purpose) {
469
920
  const purposeBody = purpose?.trim() || `TBD - created by archiving change ${changeName}. Update Purpose after archive.`;
470
921
  return `# ${titleBase} Specification\n\n## Purpose\n${purposeBody}\n\n## Requirements\n`;
471
922
  }
472
- function findMissingCurrentScenarios(current, incoming) {
473
- // Multiplicity-aware: a name present N times in current and M times in
474
- // incoming means max(0, N - M) instances are missing. Set membership would
475
- // treat N>M as fully covered and let archive silently drop duplicates
476
- // (residual #1246 / duplicate-scenario-name blind spot).
477
- const remainingIncoming = new Map();
478
- for (const scenario of parseScenarioBlocks(incoming.raw)) {
479
- const name = scenario.name;
480
- remainingIncoming.set(name, (remainingIncoming.get(name) ?? 0) + 1);
481
- }
482
- const missing = [];
483
- for (const scenario of parseScenarioBlocks(current.raw)) {
484
- const name = scenario.name;
485
- const remaining = remainingIncoming.get(name) ?? 0;
486
- if (remaining > 0) {
487
- remainingIncoming.set(name, remaining - 1);
488
- }
489
- else {
490
- missing.push(name);
491
- }
492
- }
493
- return missing;
494
- }
495
- function parseScenarioBlocks(requirementRaw) {
496
- const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n');
497
- // A `#### Scenario:` inside a fenced example is not a real scenario. The
498
- // validator's countScenarios already ignores fenced lines; the drift check
499
- // must agree with it, or a fenced sample can false-abort an archive (or
500
- // mask a genuinely dropped scenario).
501
- const mask = buildCodeFenceMask(lines);
502
- const scenarios = [];
503
- let index = 0;
504
- while (index < lines.length) {
505
- const headerMatch = mask[index] ? null : lines[index].match(/^####\s*Scenario:\s*(.+)\s*$/);
506
- if (!headerMatch) {
507
- index++;
508
- continue;
509
- }
510
- const start = index;
511
- const name = headerMatch[1].trim();
512
- index++;
513
- while (index < lines.length && (mask[index] || !/^####\s*Scenario:\s*(.+)\s*$/.test(lines[index]))) {
514
- index++;
515
- }
516
- scenarios.push({
517
- name,
518
- raw: lines.slice(start, index).join('\n').trimEnd(),
519
- });
520
- }
521
- return scenarios;
522
- }
523
923
  //# sourceMappingURL=specs-apply.js.map
@@ -7,7 +7,7 @@ export type { SkillTemplate, CommandTemplate } from './types.js';
7
7
  export { getExploreSkillTemplate, getOpsxExploreCommandTemplate } from './workflows/explore.js';
8
8
  export { getNewChangeSkillTemplate, getOpsxNewCommandTemplate } from './workflows/new-change.js';
9
9
  export { getContinueChangeSkillTemplate, getOpsxContinueCommandTemplate } from './workflows/continue-change.js';
10
- export { getApplyChangeSkillTemplate, getOpsxApplyCommandTemplate } from './workflows/apply-change.js';
10
+ export { getApplyInstructions, getApplyChangeSkillTemplate, getOpsxApplyCommandTemplate } from './workflows/apply-change.js';
11
11
  export { getUpdateChangeSkillTemplate, getOpsxUpdateCommandTemplate } from './workflows/update-change.js';
12
12
  export { getFfChangeSkillTemplate, getOpsxFfCommandTemplate } from './workflows/ff-change.js';
13
13
  export { getSyncSpecsSkillTemplate, getOpsxSyncCommandTemplate } from './workflows/sync-specs.js';
@@ -6,7 +6,7 @@
6
6
  export { getExploreSkillTemplate, getOpsxExploreCommandTemplate } from './workflows/explore.js';
7
7
  export { getNewChangeSkillTemplate, getOpsxNewCommandTemplate } from './workflows/new-change.js';
8
8
  export { getContinueChangeSkillTemplate, getOpsxContinueCommandTemplate } from './workflows/continue-change.js';
9
- export { getApplyChangeSkillTemplate, getOpsxApplyCommandTemplate } from './workflows/apply-change.js';
9
+ export { getApplyInstructions, getApplyChangeSkillTemplate, getOpsxApplyCommandTemplate } from './workflows/apply-change.js';
10
10
  export { getUpdateChangeSkillTemplate, getOpsxUpdateCommandTemplate } from './workflows/update-change.js';
11
11
  export { getFfChangeSkillTemplate, getOpsxFfCommandTemplate } from './workflows/ff-change.js';
12
12
  export { getSyncSpecsSkillTemplate, getOpsxSyncCommandTemplate } from './workflows/sync-specs.js';
@@ -5,6 +5,16 @@
5
5
  * templates file into workflow-focused modules.
6
6
  */
7
7
  import type { SkillTemplate, CommandTemplate } from '../types.js';
8
+ /**
9
+ * The apply workflow instructions, authored once and rendered by both the
10
+ * skill and command surfaces. The surfaces are intentionally distinct, but
11
+ * they differ only in how they are invoked — the generation transformers
12
+ * rewrite the canonical `/opsx:<id>` tokens per surface downstream (see
13
+ * command-references.ts). The instruction text itself is shared, so the two
14
+ * cannot silently drift. Should a surface ever need genuinely different
15
+ * wording, add a parameter here and pass it from that surface's template.
16
+ */
17
+ export declare function getApplyInstructions(): string;
8
18
  export declare function getApplyChangeSkillTemplate(): SkillTemplate;
9
19
  export declare function getOpsxApplyCommandTemplate(): CommandTemplate;
10
20
  //# sourceMappingURL=apply-change.d.ts.map